Repository navigation
Expand file tree
/
Copy pathindex.ts
More file actions
1431 lines (1348 loc) · 74.6 KB
/
Copy pathindex.ts
File metadata and controls
1431 lines (1348 loc) · 74.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { contextBridge, ipcRenderer, webUtils, type IpcRendererEvent } from 'electron';
import type { AgentProvider } from '../shared/agentProvider';
import type { HireManifest } from '../shared/hire';
export type { HireManifest } from '../shared/hire';
import type { IntegrationRecord, IntegrationTemplate } from '../shared/integrations';
export type { IntegrationRecord, IntegrationTemplate } from '../shared/integrations';
import type { UpdateStatus } from '../shared/updateState';
export type { UpdateStatus } from '../shared/updateState';
import type { ToolStatus } from '../shared/toolCatalog';
export type { ToolStatus } from '../shared/toolCatalog';
import type { HeroPayload } from '../shared/heroPayload';
export type { HeroPayload } from '../shared/heroPayload';
import type { ModelCatalog } from '../shared/modelCatalogPayload';
export type { ModelCatalog, CatalogModel } from '../shared/modelCatalogPayload';
import type { HookEvent } from '../shared/hookEvents';
export type { HookEvent } from '../shared/hookEvents';
import type { LocalSkill, CatalogSkill } from '../main/skills';
export type { LocalSkill, CatalogSkill } from '../main/skills';
import type {
ContextRule, ContextTriggerConfig, OrgTriggerConfig, TriggerHistoryEntry, WebhookTrigger
} from '../shared/triggers';
export type {
ContextRule, ContextTriggerConfig, OrgTriggerConfig, TriggerHistoryEntry, WebhookTrigger
} from '../shared/triggers';
/** Renderer-visible integration record: the secretRef handle is redacted to a
* presence boolean. Matches main `integrations.listRecordsRedacted()` — the
* write-only secret contract (spec §2): a secret value is NEVER returned over IPC. */
export type IntegrationRecordView = Omit<IntegrationRecord, 'secretRef'> & { hasSecret: boolean };
// Injected at build time from package.json (see electron.vite.config.ts).
declare const __APP_VERSION__: string;
/** The renderer's roster as mirrored to `<harnessHome>/roster.json`. The agent
* entries stay `unknown` here for the same reason main leaves them opaque: the
* store owns that shape, and repeating it in the bridge would mean editing two
* files every time an agent gains a field. */
export interface RosterSnapshot {
version: 1;
savedAt: string;
agents: unknown[];
archived: unknown[];
restorable: unknown[];
queues: Record<string, unknown[]>;
selectedId: string | null;
}
export interface HiveAgentMeta {
id: string;
name: string;
/** Which CLI this agent runs on (claude/codex/grok/antigravity/custom); defaults claude. */
provider?: AgentProvider;
role?: string;
capabilities?: string[];
cwd: string;
isGod?: boolean;
/** Michael's prep assistant — send-only; enriches prompts and forwards them. */
isAssistant?: boolean;
}
export interface HiveMessage {
id: string;
conversation: string;
in_reply_to: string | null;
from: string;
to: string;
act: 'request' | 'inform' | 'propose' | 'query' | 'agree' | 'refuse' | 'done';
subject: string;
body: string;
hops: number;
requires_reply: boolean;
needs_human: boolean;
created_at: string;
}
/** A hive message reshaped for the voice read-layer (`hive:messages`). `subject`
* and `body` are REDACTED in the main process before crossing this boundary —
* the renderer never receives a raw body or a secret. Mirror of `VoiceMessage`
* in src/main/hive.ts. */
export interface VoiceMessage {
id: string;
conversation: string;
from: string;
to: string;
act: HiveMessage['act'];
subject: string;
body: string;
requires_reply: boolean;
direction: 'inbox' | 'outbox';
owner: string;
archived: boolean;
created_at: string;
}
export interface HiveRegistry {
godId: string | null;
/** `archived` agents have had their terminal closed — retained + flagged, not
* deleted; only live-PTY agents are 'active'. */
agents: Record<string, HiveAgentMeta & {
status: string;
lastSeen: number;
archived?: boolean;
sessionId?: string;
}>;
}
/** One row of the consolidated voice read-layer directory (`hive:agentDirectory`):
* everything the office-floor sidebar + telemetry know for an agent, joined into
* one PII-free record. Includes archived agents. */
export interface AgentDirectoryEntry {
id: string;
name: string;
role: string;
provider: string;
/** Live model id (normalized), if any usage has been recorded — else null. */
model: string | null;
status: string;
cwd: string | null;
/** Whether `cwd` is an absolute, existing directory (spawn-usable). */
cwdValid: boolean | null;
archived: boolean;
isGod: boolean;
isAssistant: boolean;
sessionId: string | null;
/** Whether the agent has recorded non-trivial memory beyond the seed header. */
hasMemory: boolean;
inboxBacklog: number;
breaker: string;
tokens: number;
/** Aggregate spend; carried for completeness — the voice layer speaks tokens. */
usd: number;
lastTool: string | null;
lastActiveSecAgo: number | null;
contextTokens: number | null;
contextLimit: number | null;
contextPct: number | null;
}
export interface AgentDirectory {
godId: string | null;
agents: AgentDirectoryEntry[];
}
/** One question→answer exchange with the human, recorded ON the task card. */
export interface HumanQA {
q: string;
a?: string;
askedAt?: string;
answeredAt?: string;
dismissedAt?: string;
}
/** A card on the task kanban, persisted to hive/tasks.json. */
export interface HiveTask {
id: string;
title: string;
description?: string;
assignee?: string;
status: 'todo' | 'doing' | 'blocked' | 'done';
dependsOn: string[];
priority: number;
createdAt: string;
/** First-class human feedback: god appends {q}, the harness UI fills {a};
* the full history stays on the card. */
humanQA?: HumanQA[];
/** Outcome summary used for the Slack done-notification. */
result?: string;
/** Origin thread for a Slack-sourced task (drives the done-summary reply). */
slack?: { channel: string; thread_ts: string };
/** SHA-256 of the capability token for a generic-webhook-sourced task (drives
* the GET status lookup; the raw token is never persisted). */
webhook?: { tokenHash: string };
}
/** A message the router just delivered, with its resolved recipient ids. Drives
* the envelope-handoff animation on the office floor. `needsHuman` is set when
* the sender aimed at "human" (now routed to the god proxy) — cosmetic tint
* only; there is no approval queue. */
export interface HiveRouteEvent {
id: string;
from: string;
to: string;
act: 'request' | 'inform' | 'propose' | 'query' | 'agree' | 'refuse' | 'done';
subject: string;
targets: string[];
needsHuman: boolean;
}
/** A direct hive message addressed to a provider that cannot drain hive inbox.
* The renderer turns this into a queued terminal work order for that agent. */
export interface HiveTerminalHandoffEvent {
id: string;
from: string;
to: string;
act: 'request' | 'inform' | 'propose' | 'query' | 'agree' | 'refuse' | 'done';
subject: string;
body: string;
requiresReply: boolean;
createdAt: string;
}
export interface SpawnPtyOptions {
id: string;
cwd: string;
command: string;
/** Which CLI to spawn; usually inferred from `command` in the main process. */
provider?: AgentProvider;
args?: string[];
cols?: number;
rows?: number;
/** When present, the agent is provisioned in the hive at spawn. */
hive?: HiveAgentMeta;
/** When true (and cwd is a git repo), spawn the agent in its own git worktree. */
isolate?: boolean;
/** When true, continue the agent's prior CLI session if one was recorded
* (provider-aware: Claude/Grok `--resume`, Antigravity `--conversation`). For
* Claude the main process looks up the session id from the hive registry and
* seeds its transcript into the cwd's project dir (#1 — restore on restart). */
resume?: boolean;
/** Fail before spawning when a requested resume cannot be attached. */
requireResume?: boolean;
/** Explicit Claude session id to resume (#2 — Add Agent "resume session"). The
* main process seeds that session's `.jsonl` into the target cwd's project dir
* (copying it from wherever it lives) and launches `claude --resume <id>`. */
resumeSessionId?: string;
}
export interface PtyExit { exitCode: number; signal?: number | undefined }
/** A recurring auto-dispatched mission fired on an interval by the scheduler. */
export interface ScheduledMission {
id: string;
label: string;
intervalMs: number;
to: string;
body: string;
enabled: boolean;
autoCompact?: boolean;
lastFiredAt?: number;
/** Mission flavor; 'heartbeat' (Lane A #1) is a context-aware adaptive beat. */
kind?: 'dispatch' | 'heartbeat' | 'compact';
/** Heartbeat only: floor-quiet threshold in ms. */
quietThresholdMs?: number;
}
/** Circuit-breaker thresholds (Lane A #6.6b). Mirrors src/main/config.ts. */
export interface KnowledgeGraphConfig {
enabled?: boolean;
rootPath?: string;
}
export interface CircuitBreakerConfig {
enabled?: boolean;
hardStop?: boolean;
repeatedToolLimit?: number;
errorStormLimit?: number;
tokenVelocityPerMin?: number;
}
export interface HarnessConfig {
onboardingComplete: boolean;
/** Onboarding audience ('technical' | 'non-technical'); drives onboarding copy.
* Mirrors src/main/config.ts. */
audience?: 'technical' | 'non-technical';
harnessHome: string | null;
/** Recently-opened hive home folders (most-recent first). Mirrors src/main/config.ts. */
recentHives?: string[];
registeredRepos: string[];
autoMode: boolean;
defaultCommand: string;
defaultModel?: string;
/** Which provider+model powers the GOD orchestrator ("Michael"). Default
* 'claude' / 'claude-opus-4-8'. Mirrors src/main/config.ts. */
godProvider?: AgentProvider;
godModel?: string;
/** Per-server consent for the default MCP bundle, keyed by catalog id. Mirrors
* src/main/config.ts. */
mcpDefaults?: { [id: string]: { enabled: boolean } };
semanticMemory: boolean;
embeddingModel: 'minilm' | 'embeddinggemma';
missions?: ScheduledMission[];
opsStandupSeeded?: boolean;
heartbeatSeeded?: boolean;
notifications?: boolean;
/** Opt-in strong keep-alive (prevent-display-sleep). Mirrors main + renderer
* HarnessConfig so updateConfig({ strongKeepalive }) is typed across the bridge. */
strongKeepalive?: boolean;
/** Auto-update from GitHub releases (default ON; Settings → General). */
autoUpdate?: boolean;
/** Anonymous product analytics (default ON, opt-out; see TELEMETRY.md).
* Mirrors main + renderer HarnessConfig. */
telemetryEnabled?: boolean;
slackEnabled?: boolean;
slackSigningSecret?: string;
slackBotToken?: string;
slackChannelId?: string;
slackPort?: number;
slackProactivePosting?: boolean;
webhookEnabled?: boolean;
webhookSecret?: string;
webhookPort?: number;
/** Free Flow voice dictation — master flag (default off), user Groq key, model.
* Entry point B (hold-Option-to-talk) is handled in the renderer, no hotkey. */
freeflowEnabled?: boolean;
groqApiKey?: string;
freeflowModel?: string;
/** Realtime Michael voice loop — true ONLY while a session holds the mic
* (renderer session sets it at start()/stop()); the main mic permission gate
* reads it. Default off. */
realtimeVoiceEnabled?: boolean;
/** Realtime voice idle auto-disconnect (ms); default 180000 (3 min), 0 = never.
* Tuned in Settings → Realtime Michael; the cost cap stays the runaway guard. */
realtimeIdleDisconnectMs?: number;
costCapUsd?: number;
costCapTokens?: number;
agentTokenCaps?: Record<string, number>;
autoDeliveryPausedAgents?: string[];
maxTurns?: number;
circuitBreaker?: CircuitBreakerConfig;
/** Enterprise Knowledge Graph (multimodal context for agents). Default OFF. */
knowledgeGraph?: KnowledgeGraphConfig;
/** Terminal theme, mirrored into each agent's per-session Claude settings. */
terminalTheme?: 'light' | 'dark';
/** TV-show office themes feature flag (Settings picker + switch flow). Default OFF. */
tvShowOffices?: boolean;
/** Active office map/cast theme (honored only when tvShowOffices is on). */
officeTheme?: 'office' | 'friends' | 'brooklyn99' | 'siliconvalley' | 'got' | 'hogwarts';
/** Per-CLI-provider local/self-hosted base URL (Ollama/LM Studio/vLLM, …) for the
* OpenCode/Crush/pi/qwen engines; applied at spawn. API KEYS are NOT stored here —
* they live write-only in the secret broker. */
providerBaseUrls?: Partial<Record<AgentProvider, string>>;
/** Per-CLI-provider default model slug, used to pre-fill the model picker. */
providerDefaultModels?: Partial<Record<AgentProvider, string>>;
}
export interface MemoryStatus {
available: boolean;
enabled: boolean;
active: boolean;
initialized: boolean;
palacePath: string | null;
model: 'minilm' | 'embeddinggemma';
bin: string | null;
}
/** Enterprise Knowledge Graph — corpus status, one document, and a search hit. */
export interface KnowledgeStatus {
enabled: boolean;
root: string;
docCount: number;
chunkCount: number;
byModality: Record<string, number>;
}
export interface KnowledgeDoc {
id: string;
title: string;
source: string;
modality: string;
mime: string | null;
origExt: string;
bytes: number;
tags: string[];
caption: string | null;
chunkCount: number;
addedAt: string;
extractor: string;
truncated: boolean;
}
export interface KnowledgeHit {
docId: string;
title: string;
source: string;
modality: string;
chunkIdx: number;
score: number;
snippet: string;
}
export interface KnowledgeIngestResult {
ok: boolean;
results: Array<{ ok: boolean; srcPath: string; docId?: string; chunkCount?: number; error?: string }>;
error?: string;
}
export interface DirEntry {
name: string;
isDir: boolean;
size: number;
mtime: number;
}
export interface GitCommit {
sha: string;
shortSha: string;
parents: string[];
subject: string;
author: string;
time: number;
refs: string[];
}
export interface GitStatusEntry { path: string; index: string; worktree: string }
export interface GitStatus { staged: GitStatusEntry[]; unstaged: GitStatusEntry[]; untracked: string[] }
/** A single file's two sides for a working-tree-vs-HEAD diff (see main git.getDiff). */
export interface GitDiff {
ok: true;
path: string;
relPath: string;
head: string;
working: string;
headExists: boolean;
workingExists: boolean;
isBinary: boolean;
}
/** v0.3.4 git visualization — mirrors src/main/git.ts GitCommit / GitCommitFile. */
export interface GitCommitRow {
sha: string;
shortSha: string;
parents: string[];
subject: string;
author: string;
time: number;
refs: string[];
}
export interface GitFileChange {
path: string;
status: string;
oldPath?: string;
}
/** Real token usage + estimated USD cost summed from an agent's Claude Code
* transcripts under ~/.claude/projects. Reconciler/fallback path — now priced
* PER MODEL (not Sonnet-for-everyone). The live path uses AgentUsageSample. */
export interface AgentUsage {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
estimatedCostUsd: number;
/** Most-recently-seen model id (normalized), if any priced record was found. */
model?: string;
}
/** Live cumulative cost/token snapshot from the OTel collector (the locked
* cross-lane seam). PII-free by construction. Mirrors telemetry.ts. */
export interface AgentUsageSample {
agentId: string;
sessionId: string;
ts: number;
input: number;
output: number;
cacheRead: number;
cacheCreation: number;
model: string;
usd: number;
}
/** One tool invocation for the per-agent span waterfall (#7B.2). Ephemeral. */
export interface ToolSpan {
agentId: string;
sessionId: string;
ts: number;
tool: string;
success: boolean;
durationMs: number;
decision?: 'accept' | 'reject';
error?: string;
}
/** Power-resume signal (mirrors the emitter in src/main). Fired after the Mac
* wakes from sleep / unlocks: `dead` lists PTY ids that were live before sleep
* but emitted nothing after resume (wedged terminals); `total` is how many were
* checked. The renderer auto-respawns exactly the `dead` ids. */
export interface PowerResumeEvent {
reason: string;
awayMs: number | null;
dead: string[];
total: number;
}
/** Closing-time progress event (mirrors src/main/closingTime.ts). */
export interface ClosingTimeEvent {
phase: 'started' | 'progress' | 'complete' | 'timeout' | 'cancelled';
/** Workers that have ACKed so far / total workers being waited on. */
acked: number;
total: number;
}
/** Per-agent operator-control state (#7C.1–7C.3). */
export interface AgentControlSnapshot {
paused: boolean;
halted: boolean;
autoDeliveryPaused: boolean;
gatedTools: string[];
pendingSteers: number;
}
/** Circuit-breaker state (Lane A #6 → this lane's avatars/meter). */
export interface BreakerState {
agentId: string;
level: 'healthy' | 'steering' | 'constrained' | 'stopped';
reason: string;
ts: number;
}
/** Live telemetry push payload (channel `telemetry:event`). */
export type TelemetryEvent =
| { kind: 'usage'; sample: AgentUsageSample }
| { kind: 'tool_result'; span: ToolSpan }
| { kind: 'api_error'; agentId: string; sessionId: string; ts: number; error: string };
/** Cold-start backfill from the collector. */
export interface TelemetrySnapshot {
usage: AgentUsageSample[];
spans: Record<string, ToolSpan[]>;
}
/** One captured user prompt from the SQLite command_history table. */
export interface CommandHistoryEntry {
id: number;
agentId: string;
cwd: string | null;
text: string;
ts: number;
}
/** A GitHub issue, normalized for the renderer (labels/assignees flattened to names). */
export interface GHIssue {
number: number;
title: string;
body: string;
url: string;
labels: string[];
assignees: string[];
}
/** A CI (GitHub Actions) workflow run, normalized for the renderer. */
export interface CIRun {
name: string;
status: string;
conclusion: string | null;
url: string;
}
/** One live god-triggered ephemeral worker, as shown in the Workers tab. */
export interface WorkerSnapshot {
workerId: string;
reqId: string;
name: string;
baseBranch: string;
spawnedAt: number;
ageMs: number;
idleMs: number | null; // null = PTY already gone
tokensUsed: number;
tokenCap: number | null; // effective cap; null = unlimited (the default)
hasSlack: boolean;
releasing: boolean;
status: 'releasing' | 'working';
}
/** A worker worktree preserved at teardown, awaiting integration + GC. */
export interface PreservedWorktreeSnapshot {
workerId: string;
wtPath: string;
baseBranch: string;
preservedAt: number;
}
const api = {
version: __APP_VERSION__,
// ─── Analytics ───────────────────────────────────────────────────────────
/** Count ONE human-sent message (TELEMETRY.md → `message_sent`). Carries a
* surface name and nothing else — no text, no length, no agent id — and main
* accepts only 'terminal' and 'composer' here (steer and hive are counted in
* main, at their own handlers). Never awaited by callers and never allowed to
* throw: a telemetry hiccup must not break sending a message. */
trackMessageSent: (surface: 'terminal' | 'composer'): Promise<void> =>
ipcRenderer.invoke('analytics:messageSent', surface).then(() => undefined, () => undefined),
// ─── PTY ─────────────────────────────────────────────────────────────────
/** `cwd` in the result is the TILDE-EXPANDED absolute path main actually spawned
* into — the renderer stores that, not the raw `~/…` the user typed. */
spawnPty: (opts: SpawnPtyOptions): Promise<{ ok: boolean; error?: string; cwd?: string; worktreePath?: string; resumeNotFound?: boolean; resumed?: boolean; seedPrompt?: string }> =>
ipcRenderer.invoke('pty:spawn', opts),
writePty: (id: string, data: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('pty:write', id, data),
resizePty: (id: string, cols: number, rows: number): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('pty:resize', id, cols, rows),
redrawPty: (id: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('pty:redraw', id),
killPty: (id: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('pty:kill', id),
listPtys: (): Promise<Array<{
id: string;
cwd: string;
command: string;
pid: number;
lastOutputAt: number;
hasOutput: boolean;
}>> =>
ipcRenderer.invoke('pty:list'),
/** Resolve a Claude session id to the cwd it originally ran in (Add Agent
* resume auto-fill), or null if the id is invalid/unknown. */
resolveSessionCwd: (sessionId: string): Promise<string | null> =>
ipcRenderer.invoke('session:resolveCwd', sessionId),
onPtyData: (id: string, cb: (data: string) => void): (() => void) => {
const channel = `pty:data:${id}`;
const listener = (_e: IpcRendererEvent, data: string) => cb(data);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
onPtyExit: (id: string, cb: (info: PtyExit) => void): (() => void) => {
const channel = `pty:exit:${id}`;
const listener = (_e: IpcRendererEvent, info: PtyExit) => cb(info);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
/** Fires when an agent is auto restart-and-continued into this SAME pty after a
* first-time engine-CLI install. The terminal should re-arm in place (clear the
* "process exited" line + re-enable input) so the relaunched CLI paints clean. */
onPtyRelaunch: (id: string, cb: () => void): (() => void) => {
const channel = `pty:relaunch:${id}`;
const listener = () => cb();
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
// ─── Dialog ──────────────────────────────────────────────────────────────
chooseFolder: (): Promise<{ ok: true; path: string } | { ok: false; error: string }> =>
ipcRenderer.invoke('dialog:chooseFolder'),
// ─── Terminal.app ────────────────────────────────────────────────────────
openTerminalAt: (cwd: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('terminal:openAtFolder', cwd),
// ─── Clipboard ─────────────────────────────────────────────────────────────
copyToClipboard: (text: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('app:copyToClipboard', text),
/** Read the system clipboard as plain text ('' when empty/unreadable). */
readClipboard: (): Promise<string> =>
ipcRenderer.invoke('app:readClipboard'),
/** Clipboard text, read SYNCHRONOUSLY. Only for the terminal's paste shortcut,
* where an async read loses a race against dictation tools that restore the
* previous clipboard right after sending the paste key.
*
* TRADEOFF, stated plainly because sendSync blocks the renderer until main
* answers: this app has a history of main-thread stalls (iCloud-evicted files
* wedging a spawnSync git call), and during such a stall this call freezes the
* paste keystroke rather than merely delaying it. Accepted because a clipboard
* read is a memory lookup with no I/O, and because the async alternative is
* measurably WRONG — it pastes the user's previous clipboard. Do not reach for
* sendSync elsewhere on this reasoning; it is justified by the race, not by
* convenience. */
readClipboardSync: (): string => {
try { return ipcRenderer.sendSync('app:readClipboardSync') ?? ''; } catch { return ''; }
},
// ─── Config ──────────────────────────────────────────────────────────────
getConfig: (): Promise<HarnessConfig> =>
ipcRenderer.invoke('config:get'),
updateConfig: (patch: Partial<HarnessConfig>): Promise<HarnessConfig> =>
ipcRenderer.invoke('config:update', patch),
/** Set or clear one per-agent token ceiling against main's latest config. */
setAgentTokenCap: (agentId: string, tokenCap?: number): Promise<HarnessConfig> =>
ipcRenderer.invoke('config:setAgentTokenCap', agentId, tokenCap),
ensureHarnessHome: (path: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('config:ensureHome', path),
/** Change the harness home folder. 'move' copies the existing hive + palace
* into the new folder (old kept as a safety net); 'fresh' just re-points and
* bootstraps an empty home. On success the app relaunches (never resolves);
* on failure (e.g. copy error) returns { ok: false, error }. */
changeHome: (newHome: string, mode: 'move' | 'fresh'): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('config:changeHome', { newHome, mode }),
// ─── Filesystem (sandboxed to cwd) ───────────────────────────────────────
listDir: (root: string, rel: string): Promise<
{ ok: true; entries: DirEntry[]; path: string } | { ok: false; error: string }
> => ipcRenderer.invoke('fs:listDir', root, rel),
readFile: (root: string, rel: string): Promise<
{ ok: true; content: string; path: string; size: number } | { ok: false; error: string }
> => ipcRenderer.invoke('fs:readFile', root, rel),
/** Raw bytes for files `readFile` refuses (images). The renderer has no way to
* load them off disk — the CSP allows no `file:` source and no file protocol
* is registered — so images travel as bytes and become a `blob:` URL in the
* renderer, which `img-src` already permits. Root-confined and size-capped in
* the main process; `mime` is derived from the extension. */
readBinary: (root: string, rel: string): Promise<
// `Uint8Array<ArrayBuffer>`, not the bare alias: structured clone always
// hands the renderer a view over a plain ArrayBuffer, and saying so is what
// lets the value go straight into `new Blob([...])` — the default
// `ArrayBufferLike` admits SharedArrayBuffer, which BlobPart rejects.
{ ok: true; bytes: Uint8Array<ArrayBuffer>; mime: string; path: string; size: number }
| { ok: false; error: string }
> => ipcRenderer.invoke('fs:readBinary', root, rel),
writeFile: (root: string, rel: string, content: string): Promise<
{ ok: true; path: string } | { ok: false; error: string }
> => ipcRenderer.invoke('fs:writeFile', root, rel, content),
/** v0.3.4: existence check for an absolute path (expands ~) — backs the
* terminal ⌘-click markdown flow. Metadata only, never contents. */
statAbs: (p: string): Promise<{ exists: boolean; isFile: boolean; path: string }> =>
ipcRenderer.invoke('fs:statAbs', p),
/** Show a path in the OS file browser (Finder / Explorer / the Linux default).
* Backs ⌘-click on a terminal path we have no viewer for. Reveals only — main
* never launches a file's default application, because the path came from
* agent output. */
revealPath: (p: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('fs:revealPath', p),
// ─── Git ─────────────────────────────────────────────────────────────────
gitIsRepo: (cwd: string): Promise<boolean> => ipcRenderer.invoke('git:isRepo', cwd),
/** Absolute path of the MAIN working tree `cwd` belongs to — a linked worktree
* resolves to the original repo, not to itself. null when not a git repo. */
gitMainRepo: (cwd: string): Promise<string | null> => ipcRenderer.invoke('git:mainRepo', cwd),
gitBranch: (cwd: string) =>
ipcRenderer.invoke('git:branch', cwd) as Promise<{ current: string | null; detached: boolean } | { error: string }>,
gitStatus: (cwd: string) =>
ipcRenderer.invoke('git:status', cwd) as Promise<GitStatus | { error: string }>,
gitLog: (cwd: string, n?: number) =>
ipcRenderer.invoke('git:log', cwd, n ?? 50) as Promise<GitCommit[] | { error: string }>,
gitBranches: (cwd: string) =>
ipcRenderer.invoke('git:branches', cwd) as Promise<{ local: string[]; remote: string[]; current: string | null } | { error: string }>,
gitAheadBehind: (cwd: string) =>
ipcRenderer.invoke('git:aheadBehind', cwd) as Promise<{ ahead: number; behind: number; upstream: string | null } | { error: string }>,
/** Diff one repo-root-relative file: its HEAD content vs its working-tree content.
* Path-validated main-side against `cwd`; the renderer only ever gets the two
* text sides. Backs the IDE's git-diff (Monaco DiffEditor) view. */
gitDiff: (cwd: string, relPath: string) =>
ipcRenderer.invoke('git:diff', cwd, relPath) as Promise<GitDiff | { ok: false; error: string }>,
// ── v0.3.4: history / compare / checkout (git visualization) ──
gitLogGraph: (cwd: string, n: number, skip?: number) =>
ipcRenderer.invoke('git:logGraph', cwd, n, skip ?? 0) as Promise<GitCommitRow[] | { error: string }>,
gitCommitFiles: (cwd: string, sha: string) =>
ipcRenderer.invoke('git:commitFiles', cwd, sha) as Promise<GitFileChange[] | { error: string }>,
gitShowFile: (cwd: string, rev: string, relPath: string) =>
ipcRenderer.invoke('git:showFile', cwd, rev, relPath) as Promise<
{ ok: true; exists: boolean; isBinary: boolean; content: string } | { ok: false; error: string }
>,
gitCompareRefs: (cwd: string, base: string, head: string, mode?: 'two' | 'three') =>
ipcRenderer.invoke('git:compareRefs', cwd, base, head, mode ?? 'three') as Promise<
{ ahead: number; behind: number; mergeBase: string | null; files: GitFileChange[] } | { error: string }
>,
gitWorktrees: (cwd: string) =>
ipcRenderer.invoke('git:worktrees', cwd) as Promise<
Array<{ path: string; head: string; branch: string | null }> | { error: string }
>,
gitCheckout: (cwd: string, ref: string, detach?: boolean) =>
ipcRenderer.invoke('git:checkout', cwd, ref, detach === true) as Promise<
{ ok: true; detached: boolean } | { ok: false; error: string }
>,
// ─── Hive (multi-agent coordination) ─────────────────────────────────────
hiveRegistry: (): Promise<HiveRegistry> => ipcRenderer.invoke('hive:registry'),
/** Persist a hire/job role to hive registry.json + identity.md (no respawn). */
hivePatchAgentRole: (id: string, role: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('hive:patchAgentRole', id, role),
/** Rename an agent's display name. Its id, hive directory, and PTY are unchanged. */
hiveRenameAgent: (id: string, name: string): Promise<{ ok: boolean; name?: string; error?: string }> =>
ipcRenderer.invoke('hive:renameAgent', id, name),
/** Put an agent on hold (the human has them 1:1) or take it off. Held agents
* keep running; Michael is told to stop routing work to them. */
hiveSetAgentHold: (id: string, hold: boolean): Promise<{ ok: boolean; onHold?: boolean; error?: string }> =>
ipcRenderer.invoke('hive:setAgentHold', id, hold),
hiveBoard: (): Promise<string> => ipcRenderer.invoke('hive:board'),
hiveTasks: (): Promise<unknown> => ipcRenderer.invoke('hive:tasks'),
hiveLog: (n?: number): Promise<unknown[]> => ipcRenderer.invoke('hive:log', n ?? 200),
hiveMemory: (id: string): Promise<string> => ipcRenderer.invoke('hive:memory', id),
hiveInbox: (id: string): Promise<HiveMessage[]> => ipcRenderer.invoke('hive:inbox', id),
/** Voice read-layer: recent message CONTENT (inbox/outbox bodies), REDACTED in
* main. Pass { id } for one message, { agentId } to scope to one mailbox, or
* {} for the whole floor. Backs Realtime Michael's get_messages. The renderer
* never sees a raw body or a secret — stripping happens main-side. */
hiveMessages: (opts?: { agentId?: string; id?: string; limit?: number; includeArchived?: boolean }): Promise<VoiceMessage[]> =>
ipcRenderer.invoke('hive:messages', opts ?? {}),
/** Consolidated per-agent directory (registry + telemetry + context), incl.
* archived agents. Backs Realtime Michael's get_agent_detail / list_agents. */
hiveAgentDirectory: (): Promise<AgentDirectory> => ipcRenderer.invoke('hive:agentDirectory'),
// ─── Ephemeral workers (P4 — Slack-triggered isolated workers) ───────────
/** Live ephemeral workers + worktrees preserved awaiting integration/GC. */
listWorkers: (): Promise<{ live: WorkerSnapshot[]; preserved: PreservedWorktreeSnapshot[]; maxWorkers: number }> =>
ipcRenderer.invoke('workers:list'),
/** Manually stop a live ephemeral worker (safety-gated teardown; work preserved). */
stopWorker: (workerId: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('workers:stop', workerId),
// ─── Semantic memory (MemPalace CLI) ─────────────────────────────────────
memoryStatus: (): Promise<MemoryStatus> => ipcRenderer.invoke('hive:memoryStatus'),
/** Which external tools (uv, mempalace, git, each agent engine) are actually
* present on this machine, with a platform-resolved install command each. */
toolsStatus: (): Promise<ToolStatus[]> => ipcRenderer.invoke('tools:status'),
/** Settings hero payload — plan + sponsor, fetched from the repo and cached. */
heroPayload: (force?: boolean): Promise<{ hero: HeroPayload; fetchedAt: number; stale: boolean }> =>
ipcRenderer.invoke('hero:payload', force),
/** The remote model catalog — the agent model presets, fetched from the repo
* and cached, so a new model needs a JSON edit rather than a release. A null
* catalog means the renderer keeps the list compiled into the build. */
modelCatalog: (force?: boolean): Promise<{
catalog: ModelCatalog | null; fetchedAt: number; stale: boolean;
}> => ipcRenderer.invoke('models:catalog', force),
/** Skills already installed for the coding agents on this machine. */
skillsLocal: (cwd?: string): Promise<LocalSkill[]> => ipcRenderer.invoke('skills:local', cwd),
/** The browsable skills catalog (cached; `force` re-fetches). */
skillsCatalog: (force?: boolean): Promise<{
skills: CatalogSkill[]; fetchedAt: number; stale: boolean; error?: string;
}> => ipcRenderer.invoke('skills:catalog', force),
/** Install a catalog skill into ~/.claude/skills. `unsupported` distinguishes
* "there is no downloadable source" from "the download failed". */
skillsInstall: (url: string, name: string): Promise<
{ ok: true; path: string } | { ok: false; error: string; unsupported?: boolean }
> => ipcRenderer.invoke('skills:install', url, name),
/** Delete an installed skill. Main refuses any path outside a skills root. */
skillsUninstall: (path: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('skills:uninstall', path),
/** Show a skill's folder in the OS file manager. */
skillsReveal: (path: string): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('skills:reveal', path),
searchMemory: (query: string, wing?: string): Promise<{ ok: boolean; output: string; error?: string }> =>
ipcRenderer.invoke('hive:searchMemory', query, wing),
memoryWakeUp: (wing?: string): Promise<{ ok: boolean; output: string; error?: string }> =>
ipcRenderer.invoke('hive:memoryWakeUp', wing),
mineNow: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('hive:mineNow'),
/** Condense agent memory.md files (the janitor's missing half). With an id,
* condense that agent on demand; without, run a full threshold scan. Returns
* the per-agent outcomes ({ id, condensed, reason, oldBytes?, newBytes? }). */
reflectNow: (id?: string): Promise<Array<{ id: string; condensed: boolean; reason: string; oldBytes?: number; newBytes?: number }>> =>
ipcRenderer.invoke('memory:reflectNow', id),
// ─── Enterprise Knowledge Graph (multimodal context for agents) ───────────
kgStatus: (): Promise<KnowledgeStatus> => ipcRenderer.invoke('kg:status'),
kgList: (): Promise<KnowledgeDoc[]> => ipcRenderer.invoke('kg:list'),
kgSearch: (query: string, limit?: number): Promise<KnowledgeHit[]> =>
ipcRenderer.invoke('kg:search', query, limit),
kgGet: (id: string): Promise<{ meta: KnowledgeDoc; text: string } | null> =>
ipcRenderer.invoke('kg:get', id),
kgRemove: (id: string): Promise<{ ok: boolean }> => ipcRenderer.invoke('kg:remove', id),
/** Open an OS file picker and ingest the chosen artifacts in one round-trip. */
kgAddFiles: (): Promise<KnowledgeIngestResult> => ipcRenderer.invoke('kg:addFiles'),
/** Ingest explicit file paths (e.g. drag-and-drop). */
kgIngestFiles: (paths: string[], tags?: string[]): Promise<KnowledgeIngestResult> =>
ipcRenderer.invoke('kg:ingestFiles', { paths, tags }),
// ─── Composer attachments (images + files, sent to agents by PATH) ─────────
/** Open an OS picker for images/files; returns chosen absolute paths + names. */
attachFiles: (): Promise<
{ ok: true; files: { path: string; name: string }[] } | { ok: false; error: string }
> => ipcRenderer.invoke('dialog:attachFiles'),
/** Resolve a dropped File's absolute path (Electron 32 removed File.path). */
pathForFile: (file: File): string => webUtils.getPathForFile(file),
/** Write the current clipboard image to a temp PNG and return its path (paste-to-attach). */
saveClipboardImage: (): Promise<
{ ok: true; file: { path: string; name: string } } | { ok: false; error: string }
> => ipcRenderer.invoke('clipboard:saveImage'),
// ─── Command history (SQLite — every prompt submitted to an agent) ─────────
/** Record one submitted prompt. Fire-and-forget from the prompt-detection hook. */
historyAdd: (entry: { agentId: string; cwd?: string; text: string }): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('history:add', entry),
/** Most-recent-first history, optionally scoped to one agent. */
historyList: (agentId?: string, limit?: number): Promise<CommandHistoryEntry[]> =>
ipcRenderer.invoke('history:list', agentId, limit),
/** Substring search over prompt text, most-recent-first. */
historySearch: (query: string, limit?: number): Promise<CommandHistoryEntry[]> =>
ipcRenderer.invoke('history:search', query, limit),
hiveSend: (msg: Partial<HiveMessage>, from?: string): Promise<{ ok: boolean; error?: string; message?: HiveMessage }> =>
ipcRenderer.invoke('hive:send', msg, from),
onHiveHookEvent: (
cb: (e: HookEvent) => void
): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: HookEvent) => cb(payload);
ipcRenderer.on('hive:hookEvent', listener);
return () => ipcRenderer.removeListener('hive:hookEvent', listener);
},
/** Push-based context accounting from the status line: live tokens + the
* session's EXACT context-window size. Same pattern as onHiveHookEvent. */
onHiveContextUpdate: (
cb: (e: { agentId: string; tokens: number; limit: number }) => void
): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: { agentId: string; tokens: number; limit: number }) => cb(payload);
ipcRenderer.on('hive:contextUpdate', listener);
return () => ipcRenderer.removeListener('hive:contextUpdate', listener);
},
onHiveMessage: (cb: (e: HiveRouteEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: HiveRouteEvent) => cb(payload);
ipcRenderer.on('hive:message', listener);
return () => ipcRenderer.removeListener('hive:message', listener);
},
/** Register a listener for hive tasks routed to non-Claude agents (e.g.
* Codex). Main emits this instead of bouncing; the renderer enqueues the
* raw text so the drain effect types it into the agent's REPL when idle. */
onHiveEnqueue: (cb: (e: { targetId: string; text: string }) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: { targetId: string; text: string }) => cb(payload);
ipcRenderer.on('hive:enqueueToAgent', listener);
return () => ipcRenderer.removeListener('hive:enqueueToAgent', listener);
},
/** A MAIN-initiated agent spawn (e.g. a voice hire via rt-5) — the renderer adds
* the floor card from this descriptor since it didn't initiate the hire itself. */
onHiveAgentSpawned: (
cb: (rec: {
id: string; name: string; provider?: string; cwd: string;
command?: string; role?: string; worktreePath?: string;
character?: string; accent?: string;
}) => void
): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: Parameters<typeof cb>[0]) => cb(payload);
ipcRenderer.on('hive:agentSpawned', listener);
return () => ipcRenderer.removeListener('hive:agentSpawned', listener);
},
/** A MAIN-initiated agent kill/archive (e.g. a voice kill via rt-5) — the renderer
* archives the floor card since it didn't initiate the kill itself. */
onHiveAgentArchived: (cb: (e: { id: string }) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: { id: string }) => cb(payload);
ipcRenderer.on('hive:agentArchived', listener);
return () => ipcRenderer.removeListener('hive:agentArchived', listener);
},
/** Register a listener for terminal work-order handoffs (#53) — hive mail to a
* hookless provider that can't drain an inbox; the renderer types it into the
* agent's REPL as a work order. */
onHiveTerminalHandoff: (cb: (e: HiveTerminalHandoffEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: HiveTerminalHandoffEvent) => cb(payload);
ipcRenderer.on('hive:terminalHandoff', listener);
return () => ipcRenderer.removeListener('hive:terminalHandoff', listener);
},
// ─── Shareable hires (deep link / file import) ────────────────────────────
/** Fired when a validated hire manifest arrives via the munderdifflin://
* deep link. The renderer opens the Add-Agent modal pre-filled — import
* never spawns anything by itself. */
onHireImport: (cb: (manifest: HireManifest) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, manifest: HireManifest) => cb(manifest);
ipcRenderer.on('hire:import', listener);
return () => ipcRenderer.removeListener('hire:import', listener);
},
/** Fired when a deep-linked manifest failed validation/fetch. */
onHireError: (cb: (info: { error: string }) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, info: { error: string }) => cb(info);
ipcRenderer.on('hire:error', listener);
return () => ipcRenderer.removeListener('hire:error', listener);
},
/** Signal readiness and pull any queued deep-linked manifests (cold-start
* links, links that arrived during load). Resolves the queued list. */
drainPendingHires: (): Promise<HireManifest[]> =>
ipcRenderer.invoke('hire:drainPending'),
/** Open a multi-file picker and validate every selected hire manifest. */
importHireFiles: (): Promise<{
ok: boolean;
manifests: HireManifest[];
errors: string[];
error?: string;
}> =>
ipcRenderer.invoke('hire:openFile'),
// ─── Config changes ──────────────────────────────────────────────────────
/** Fired whenever a setting is saved, with the full updated config. */
onConfigChanged: (cb: (config: HarnessConfig) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, config: HarnessConfig) => cb(config);
ipcRenderer.on('config:changed', listener);
return () => ipcRenderer.removeListener('config:changed', listener);
},
// ─── Quit confirmation ───────────────────────────────────────────────────
onCloseRequested: (cb: (info: { ptyCount: number }) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, info: { ptyCount: number }) => cb(info);
ipcRenderer.on('app:closeRequested', listener);
return () => ipcRenderer.removeListener('app:closeRequested', listener);
},
confirmClose: (): Promise<void> => ipcRenderer.invoke('app:confirmClose'),
cancelClose: (): Promise<void> => ipcRenderer.invoke('app:cancelClose'),
// ─── Power / wake (auto-revive wedged PTYs after sleep/lock) ────────────────
/** Subscribe to the main-process power-resume signal; returns an unsubscribe
* fn. The main process catches up after a sleep/unlock and reports the PTY
* ids that wedged across it in `dead` — the renderer respawns ONLY those
* (empty `dead[]` = no-op). Same main→renderer push pattern as onClosingTime. */
onPowerResume: (cb: (e: PowerResumeEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, payload: PowerResumeEvent) => cb(payload);
ipcRenderer.on('power:resume', listener);
return () => ipcRenderer.removeListener('power:resume', listener);
},
// ─── Multi-window floors ───────────────────────────────────────────────────
/** Open a new floor (independent office window). No-op when the multiWindow
* flag is off. Resolves { ok } indicating whether a window opened. */
newFloor: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('window:newFloor'),
// ─── Closing time (graceful shutdown via the hive) ─────────────────────────
/** Start the closing-time protocol: the god broadcasts shutdown, every worker
* saves its memory and ACKs, the god concludes — then the app quits itself.
* Resolves with ok:false (+ error) when no god agent is running. */
startClosingTime: (): Promise<{ ok: boolean; error?: string }> =>
ipcRenderer.invoke('app:startClosingTime'),
/** Abort an in-progress closing time and tell the floor to resume work. */
cancelClosingTime: (): Promise<void> => ipcRenderer.invoke('app:cancelClosingTime'),
/** Progress events for the quit dialog: started → progress (ACK counts) →
* complete (the app tears down moments later) | timeout | cancelled. */
onClosingTime: (cb: (ev: ClosingTimeEvent) => void): (() => void) => {
const listener = (_e: IpcRendererEvent, ev: ClosingTimeEvent) => cb(ev);
ipcRenderer.on('app:closingTime', listener);
return () => ipcRenderer.removeListener('app:closingTime', listener);
},