agentbox-app 0.1.13 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +60 -34
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +8 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +6 -0
- package/shared/notify-rules.mjs +36 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
|
@@ -95,6 +95,11 @@ export class MemoryGateServer {
|
|
|
95
95
|
socketPath = defaultSocketPath(),
|
|
96
96
|
historyFile = null,
|
|
97
97
|
history = null,
|
|
98
|
+
// One appended line per grant, so what the slots cost can be read later.
|
|
99
|
+
// Without it the only duration anywhere is a transcript's, which is waiting
|
|
100
|
+
// and running added together and cannot be separated.
|
|
101
|
+
grantLogFile = null,
|
|
102
|
+
grantLogMaxBytes = 4 * 1024 * 1024,
|
|
98
103
|
gate = {},
|
|
99
104
|
scoreFor = null,
|
|
100
105
|
// WHOSE CODEX THREAD IS THIS (w-e5225b62ba). A Claude worker carries its row
|
|
@@ -128,6 +133,8 @@ export class MemoryGateServer {
|
|
|
128
133
|
} = {}) {
|
|
129
134
|
this.socketPath = socketPath;
|
|
130
135
|
this.historyFile = historyFile;
|
|
136
|
+
this.grantLogFile = grantLogFile;
|
|
137
|
+
this.grantLogMaxBytes = grantLogMaxBytes;
|
|
131
138
|
this.history = history ?? loadHistory(historyFile);
|
|
132
139
|
this.clock = clock;
|
|
133
140
|
this.idleReleaseMs = idleReleaseMs;
|
|
@@ -355,6 +362,10 @@ export class MemoryGateServer {
|
|
|
355
362
|
res.end('');
|
|
356
363
|
} else {
|
|
357
364
|
this.counts.refused++;
|
|
365
|
+
this._writeGrant({
|
|
366
|
+
refused: true, cls: decision.cls, waitMs: decision.waitedMs, ranMs: null, peakMb: null,
|
|
367
|
+
pressure: decision.pressure, command,
|
|
368
|
+
});
|
|
358
369
|
res.end(denyOutput(decision.reason));
|
|
359
370
|
}
|
|
360
371
|
});
|
|
@@ -372,6 +383,16 @@ export class MemoryGateServer {
|
|
|
372
383
|
this.grants.delete(id);
|
|
373
384
|
const was = this.gate.finish(id);
|
|
374
385
|
if (!was) return;
|
|
386
|
+
this._writeGrant({
|
|
387
|
+
refused: false,
|
|
388
|
+
cls: was.cls,
|
|
389
|
+
waitMs: was.waitedMs ?? 0,
|
|
390
|
+
ranMs: this.clock() - was.startedAt,
|
|
391
|
+
peakMb: grant?.ambiguous ? null : was.peakMb,
|
|
392
|
+
shared: !!grant?.ambiguous,
|
|
393
|
+
pressure: was.pressureAt,
|
|
394
|
+
command: was.command,
|
|
395
|
+
});
|
|
375
396
|
// A SHARED READING IS NOT NO READING (w-5601e99977). Every Codex thread of
|
|
376
397
|
// a login runs under one `codex app-server`, so two Codex commands at once
|
|
377
398
|
// are measured together and neither may claim the figure. How long each
|
|
@@ -500,6 +521,47 @@ export class MemoryGateServer {
|
|
|
500
521
|
this.lastPids = new Set(procs.map((p) => p.pid));
|
|
501
522
|
}
|
|
502
523
|
|
|
524
|
+
/**
|
|
525
|
+
* One line per grant: what it was, how long it queued, how long it ran and
|
|
526
|
+
* its measured peak. `shared` says the peak is null because two commands on
|
|
527
|
+
* one agent process were measured together, without which a week of this
|
|
528
|
+
* reads as two commands where there was one.
|
|
529
|
+
*
|
|
530
|
+
* Every shell command on the Mac waits on this path, so a write that fails
|
|
531
|
+
* is logged and swallowed rather than returned to the agent.
|
|
532
|
+
*/
|
|
533
|
+
_writeGrant({ refused, cls, waitMs, ranMs, peakMb, shared = false, pressure = null, command }) {
|
|
534
|
+
if (!this.grantLogFile) return;
|
|
535
|
+
try {
|
|
536
|
+
if (this.grantBytes === undefined) {
|
|
537
|
+
try { this.grantBytes = fs.statSync(this.grantLogFile).size; } catch { this.grantBytes = 0; }
|
|
538
|
+
}
|
|
539
|
+
// Bounded, and the one before it is kept, so a week is still readable
|
|
540
|
+
// across the two even when a busy day fills one.
|
|
541
|
+
if (this.grantBytes >= this.grantLogMaxBytes) {
|
|
542
|
+
fs.renameSync(this.grantLogFile, `${this.grantLogFile}.1`);
|
|
543
|
+
this.grantBytes = 0;
|
|
544
|
+
}
|
|
545
|
+
const line = `${JSON.stringify({
|
|
546
|
+
t: this.clock(),
|
|
547
|
+
cls: cls ?? null,
|
|
548
|
+
waitMs: Number.isFinite(waitMs) ? waitMs : null,
|
|
549
|
+
ranMs: Number.isFinite(ranMs) ? ranMs : null,
|
|
550
|
+
peakMb: Number.isFinite(peakMb) ? peakMb : null,
|
|
551
|
+
// Only a wait that happened while this was `normal` is evidence about
|
|
552
|
+
// the slot count; under `tight` nothing heavy starts whatever it is.
|
|
553
|
+
pressure: pressure ?? null,
|
|
554
|
+
...(shared ? { shared: true } : {}),
|
|
555
|
+
...(refused ? { refused: true } : {}),
|
|
556
|
+
cmd: String(command ?? '').slice(0, 300),
|
|
557
|
+
})}\n`;
|
|
558
|
+
fs.appendFileSync(this.grantLogFile, line);
|
|
559
|
+
this.grantBytes += Buffer.byteLength(line);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
this.log(`memory gate: could not write the grant log: ${err.message}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
503
565
|
_saveHistory() {
|
|
504
566
|
if (!this.historyFile || !this.dirty) return;
|
|
505
567
|
try {
|
package/main/memory-gate.mjs
CHANGED
|
@@ -206,7 +206,15 @@ export class MemoryGate {
|
|
|
206
206
|
for (const w of [...this.waiting.values()]) {
|
|
207
207
|
if (now - w.arrived >= this.maxWaitMs) {
|
|
208
208
|
this.waiting.delete(w.id);
|
|
209
|
-
for (const r of w.replies)
|
|
209
|
+
for (const r of w.replies) {
|
|
210
|
+
r({
|
|
211
|
+
reason: refusal(now - w.arrived, this.pressure),
|
|
212
|
+
allow: false,
|
|
213
|
+
cls: w.cls,
|
|
214
|
+
waitedMs: now - w.arrived,
|
|
215
|
+
pressure: this.pressure,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
210
218
|
}
|
|
211
219
|
}
|
|
212
220
|
if (this.pressure === 'critical') return;
|
|
@@ -241,6 +249,11 @@ export class MemoryGate {
|
|
|
241
249
|
this.running.set(w.id, {
|
|
242
250
|
id: w.id, item: w.item, product: w.product, command: w.command, cls: w.cls,
|
|
243
251
|
urgent: w.urgent, session: w.session, background: w.background, startedAt: now, peakMb: null, shared: null,
|
|
252
|
+
// Only the coordinator reads these, to write down what the grant cost.
|
|
253
|
+
// The pressure is what makes the wait mean anything: under `normal` only
|
|
254
|
+
// full slots can hold a command back, so the wait is evidence about
|
|
255
|
+
// `slots`; under `tight` nothing heavy starts whatever the count is.
|
|
256
|
+
waitedMs: now - w.arrived, pressureAt: this.pressure,
|
|
244
257
|
});
|
|
245
258
|
for (const r of w.replies) r({ allow: true, cls: w.cls });
|
|
246
259
|
}
|
package/main/menu.mjs
CHANGED
|
@@ -159,8 +159,10 @@ export function installMenu({ onApproval, onReload, onZoom } = {}) {
|
|
|
159
159
|
{
|
|
160
160
|
label: 'Agents',
|
|
161
161
|
submenu: [
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// CommandOrControl like every other accelerator here: Command alone
|
|
163
|
+
// left the approval card's hint naming two dead keys on Linux.
|
|
164
|
+
{ label: 'Allow Pending Approval', accelerator: 'CommandOrControl+Y', click: () => onApproval?.(true) },
|
|
165
|
+
{ label: 'Deny Pending Approval', accelerator: 'CommandOrControl+N', click: () => onApproval?.(false) },
|
|
164
166
|
],
|
|
165
167
|
},
|
|
166
168
|
{
|
|
@@ -48,8 +48,8 @@ export function wantsPriority(item, product, me) {
|
|
|
48
48
|
// AND NEVER OVER THE LEVEL THE SENDER PICKED (w-7ba439c883). A message now
|
|
49
49
|
// crosses carrying how urgent the person who wrote it thought it was, and a
|
|
50
50
|
// guess from here would overwrite it: the sender's line is composeItem's, so
|
|
51
|
-
// it claims no authority the fold would defend, and a
|
|
52
|
-
//
|
|
51
|
+
// it claims no authority the fold would defend, and a guess from here is not
|
|
52
|
+
// what the sender said.
|
|
53
53
|
// A row nobody chose a level for is composeItem's 0, so a number above it is
|
|
54
54
|
// exactly "somebody picked one", and `by` says it was not this Mac.
|
|
55
55
|
if (set?.by && set.by !== me && Number.isFinite(item.priority) && item.priority > 0) return false;
|
package/main/mic.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Getting the microphone with one press, for a voice note.
|
|
2
|
+
//
|
|
3
|
+
// macOS asks on behalf of the app that opened Agentbox (Terminal, when it is
|
|
4
|
+
// run from a checkout), and once that app has been refused it never shows its
|
|
5
|
+
// Allow box again: asking just answers no. So the press clears that one
|
|
6
|
+
// refusal, for that one app and the microphone only, and asks again, which
|
|
7
|
+
// brings the Allow box back. Only ever on the press, never on its own.
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
|
|
10
|
+
export const SETTINGS_URL = 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone';
|
|
11
|
+
|
|
12
|
+
const NAMES = {
|
|
13
|
+
'com.apple.Terminal': 'Terminal',
|
|
14
|
+
'com.googlecode.iterm2': 'iTerm',
|
|
15
|
+
'com.microsoft.VSCode': 'VS Code',
|
|
16
|
+
'dev.warp.Warp-Stable': 'Warp',
|
|
17
|
+
'com.mitchellh.ghostty': 'Ghostty',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The app macOS asks about, from the bundle id macOS gave this process. */
|
|
21
|
+
export function micOwner(env = process.env) {
|
|
22
|
+
const id = env.__CFBundleIdentifier;
|
|
23
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id)) return { id: null, name: null };
|
|
24
|
+
return { id, name: NAMES[id] ?? id.split('.').at(-1) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 'granted', or 'settings' once the Settings page has been opened instead. */
|
|
28
|
+
export async function getMic({ systemPreferences, openExternal, owner = micOwner(), run = execFile }) {
|
|
29
|
+
const status = systemPreferences.getMediaAccessStatus('microphone');
|
|
30
|
+
if (status === 'granted') return 'granted';
|
|
31
|
+
if (status === 'denied' && owner.id) {
|
|
32
|
+
await new Promise((resolve) => run('tccutil', ['reset', 'Microphone', owner.id], { timeout: 10_000 }, () => resolve()));
|
|
33
|
+
}
|
|
34
|
+
const granted = await systemPreferences.askForMediaAccess('microphone').catch(() => false);
|
|
35
|
+
if (granted) return 'granted';
|
|
36
|
+
openExternal(SETTINGS_URL);
|
|
37
|
+
return 'settings';
|
|
38
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// NEW CHATS COME IN ON THEIR OWN, when somebody ticked "Keep bringing in new
|
|
2
|
+
// chats from Claude Code and Codex" on setup's last screen or turned it on in
|
|
3
|
+
// Settings (`importNew`, from the moment `importNewSince`).
|
|
4
|
+
//
|
|
5
|
+
// A chat comes in when it was started after that moment, in a folder one of
|
|
6
|
+
// the projects points at, and has gone quiet. Claude Code may still have it
|
|
7
|
+
// open: people leave it open for hours, and a reply here to an open chat
|
|
8
|
+
// carries on a copy (Supervisor#importedThreadFor). It is filed the way setup
|
|
9
|
+
// files one: waiting on you to Needs you, finished to Done. Nothing asks about
|
|
10
|
+
// a chat first; asking rows are what the old Codex watch was removed for.
|
|
11
|
+
import { productsForThread } from '../shared/codex-import.mjs';
|
|
12
|
+
import { isShared } from '../shared/team-rules.mjs';
|
|
13
|
+
import { appSessionFolders } from './agent-homes.mjs';
|
|
14
|
+
import { outsideAgentsMode } from './settings.mjs';
|
|
15
|
+
import { claudeSessionOpen } from './claude-session-open.mjs';
|
|
16
|
+
import { readCodexThreadsOffThread } from './codex-threads.mjs';
|
|
17
|
+
import { readRecentThreads } from './importable-threads.mjs';
|
|
18
|
+
|
|
19
|
+
export const NEW_CHATS_POLL_MS = 60_000;
|
|
20
|
+
/** How long a chat has to have been still before it comes in. */
|
|
21
|
+
export const QUIET_MS = 10 * 60_000;
|
|
22
|
+
const PASS_DEADLINE_MS = 90_000;
|
|
23
|
+
|
|
24
|
+
function idsWithRows(items) {
|
|
25
|
+
const ids = new Set();
|
|
26
|
+
for (const item of items ?? []) {
|
|
27
|
+
for (const label of item?.labels ?? []) {
|
|
28
|
+
if (typeof label !== 'string') continue;
|
|
29
|
+
if (label.startsWith('thread:')) ids.add(label.slice(7));
|
|
30
|
+
else if (label.startsWith('codex:')) ids.add(label.slice(6));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return ids;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Which of `threads` come in now, grouped by the project each lands in. */
|
|
37
|
+
export function newChatsToBring(threads, { products, items, since, now, isOpen = () => false, openShownLive = false }) {
|
|
38
|
+
const had = idsWithRows(items);
|
|
39
|
+
const bySlug = new Map();
|
|
40
|
+
for (const t of threads ?? []) {
|
|
41
|
+
const id = String(t?.id ?? '').trim();
|
|
42
|
+
if (!id || had.has(id)) continue;
|
|
43
|
+
if (!(Number(t.startedAt) > since)) continue;
|
|
44
|
+
if (now - Number(t.when ?? 0) < QUIET_MS) continue;
|
|
45
|
+
if (t.source === 'codex' ? t.live : openShownLive && isOpen(t)) continue;
|
|
46
|
+
const slug = productsForThread(t, products)[0]?.slug;
|
|
47
|
+
if (!slug) continue;
|
|
48
|
+
if (!bySlug.has(slug)) bySlug.set(slug, []);
|
|
49
|
+
bySlug.get(slug).push(t);
|
|
50
|
+
had.add(id);
|
|
51
|
+
}
|
|
52
|
+
return bySlug;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function scanNewChats({ store, config, readThreads, isOpen, now = Date.now() }) {
|
|
56
|
+
if (config?.importNew !== true) return { brought: 0 };
|
|
57
|
+
const since = Number(config.importNewSince) || now;
|
|
58
|
+
// Only as far back as the switch, and never further than the import card reads.
|
|
59
|
+
const days = Math.min(10, Math.max(1, Math.ceil((now - since) / 86400e3) + 1));
|
|
60
|
+
// Never a project shared with a team: teammates would read chats nobody chose to share.
|
|
61
|
+
const products = store.listProducts().filter((p) => !isShared(p));
|
|
62
|
+
const folders = products.map((p) => p.repoPath).filter(Boolean);
|
|
63
|
+
const threads = await readThreads({ days, since, folders, now });
|
|
64
|
+
// An open session already has its own row when "Show while they run" is on.
|
|
65
|
+
const openShownLive = outsideAgentsMode(config) !== 'off';
|
|
66
|
+
const picks = newChatsToBring(threads, { products, items: store.listItems(), since, now, isOpen, openShownLive });
|
|
67
|
+
let brought = 0;
|
|
68
|
+
for (const [slug, list] of picks) {
|
|
69
|
+
brought += store.importThreadRows(slug, list, { now, land: 'sorted' }).added;
|
|
70
|
+
brought += store.importCodexThreads(slug, list, { now, land: 'sorted' }).added;
|
|
71
|
+
}
|
|
72
|
+
return { brought };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function startNewChatsWatch({
|
|
76
|
+
store, config, readThreads, isOpen,
|
|
77
|
+
everyMs = NEW_CHATS_POLL_MS, firstDelayMs = 30_000, deadlineMs = PASS_DEADLINE_MS, log = console.warn,
|
|
78
|
+
}) {
|
|
79
|
+
// One pass at a time, and a pass that never answers is given up on so the
|
|
80
|
+
// next one still runs.
|
|
81
|
+
let running = null;
|
|
82
|
+
const run = () => {
|
|
83
|
+
if (running) return running;
|
|
84
|
+
let timer = null;
|
|
85
|
+
const deadline = new Promise((_, reject) => {
|
|
86
|
+
timer = setTimeout(() => reject(new Error(`no answer in ${Math.round(deadlineMs / 1000)}s`)), deadlineMs);
|
|
87
|
+
});
|
|
88
|
+
running = Promise.race([scanNewChats({ store, config, readThreads, isOpen }), deadline])
|
|
89
|
+
.catch((e) => log('new chats watch:', e?.message ?? e))
|
|
90
|
+
.finally(() => { clearTimeout(timer); running = null; });
|
|
91
|
+
return running;
|
|
92
|
+
};
|
|
93
|
+
const first = setTimeout(run, firstDelayMs);
|
|
94
|
+
const timer = setInterval(run, everyMs);
|
|
95
|
+
return {
|
|
96
|
+
scanNow: run,
|
|
97
|
+
stop: () => { clearTimeout(first); clearInterval(timer); },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The watch as the app runs it, on the desktop (main/main.mjs) and in the browser copy (bin/agentbox.mjs). */
|
|
102
|
+
export function watchNewChatsFor({ store, config, supervisor, ...timing }) {
|
|
103
|
+
return startNewChatsWatch({
|
|
104
|
+
store,
|
|
105
|
+
config,
|
|
106
|
+
readThreads: (opts) => readRecentThreads({ ...opts, config, supervisor, readCodex: readCodexThreadsOffThread }),
|
|
107
|
+
isOpen: (t) => claudeSessionOpen(t.id, appSessionFolders({ config, supervisor }).dirs),
|
|
108
|
+
...timing,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// A throwaway copy's window says so, and stands where you can see both.
|
|
2
|
+
//
|
|
3
|
+
// "Open Agentbox as a new user" (main/fresh-user.mjs) launches the app again
|
|
4
|
+
// inside a throwaway home, beside the one it was opened from. The offset is a
|
|
5
|
+
// window's shoulder rather than a tile: the point is to see there are two, with
|
|
6
|
+
// the real one still readable behind. A copy that cannot fit the offset is left
|
|
7
|
+
// centred rather than pushed half off the screen, which a 1440-wide laptop is.
|
|
8
|
+
|
|
9
|
+
/** How far down and across a second copy stands. */
|
|
10
|
+
export const OFFSET = 48;
|
|
11
|
+
|
|
12
|
+
/** The window options a copy adds, given whether it is a throwaway one. */
|
|
13
|
+
export function newUserWindow({ fresh, name, screen, width = 1440, height = 900 } = {}) {
|
|
14
|
+
if (!fresh) return { title: name };
|
|
15
|
+
const title = `${name} (new user)`;
|
|
16
|
+
if (!screen) return { title };
|
|
17
|
+
const centred = {
|
|
18
|
+
x: Math.round(screen.x + (screen.width - width) / 2),
|
|
19
|
+
y: Math.round(screen.y + (screen.height - height) / 2),
|
|
20
|
+
};
|
|
21
|
+
const x = centred.x + OFFSET;
|
|
22
|
+
const y = centred.y + OFFSET;
|
|
23
|
+
if (x + width > screen.x + screen.width || y + height > screen.y + screen.height) return { title };
|
|
24
|
+
return { title, x, y };
|
|
25
|
+
}
|
package/main/notify.mjs
CHANGED
|
@@ -52,10 +52,15 @@ export function markFor({ root, createFromPath }) {
|
|
|
52
52
|
// with one sound instead of four rewrites and four chimes.
|
|
53
53
|
const COALESCE_MS = 1500;
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
// `onChange` hears that what it has told you about changed: the tray counts
|
|
56
|
+
// all of it (main/corner-tag.mjs), so a banner never speaks for a thread the
|
|
57
|
+
// tray leaves out.
|
|
58
|
+
export function createNotifier({ window, Notification, powerMonitor, icon = null, coalesceMs = COALESCE_MS, onChange = () => {} }) {
|
|
56
59
|
// Everything she has not seen since she last had the window, keyed by id so
|
|
57
60
|
// a repeated push is idempotent. Insertion order is arrival order.
|
|
58
61
|
const unseen = new Map();
|
|
62
|
+
const toldAt = new Map();
|
|
63
|
+
const changed = () => { try { onChange(); } catch { /* the tray catches up on its next look */ } };
|
|
59
64
|
let live = null; // the one notification currently on screen, if any
|
|
60
65
|
let timer = null;
|
|
61
66
|
// HOW MANY TIMES SHE HAS BEEN INTERRUPTED SINCE SHE LAST HAD THE WINDOW.
|
|
@@ -83,11 +88,14 @@ export function createNotifier({ window, Notification, powerMonitor, icon = null
|
|
|
83
88
|
// dropped rather than banked: showing them later, when she steps away, would
|
|
84
89
|
// be telling her about work she already watched arrive.
|
|
85
90
|
const seen = () => {
|
|
91
|
+
const had = unseen.size > 0;
|
|
86
92
|
unseen.clear();
|
|
93
|
+
toldAt.clear();
|
|
87
94
|
if (timer) { clearTimeout(timer); timer = null; }
|
|
88
95
|
spoke = false;
|
|
89
96
|
spokeAsk = false;
|
|
90
97
|
takeDown();
|
|
98
|
+
if (had) changed();
|
|
91
99
|
};
|
|
92
100
|
|
|
93
101
|
const speak = () => {
|
|
@@ -156,34 +164,42 @@ export function createNotifier({ window, Notification, powerMonitor, icon = null
|
|
|
156
164
|
// Asked before banking anything, so that a burst arriving while she is
|
|
157
165
|
// at the app is dropped rather than saved up for when she leaves.
|
|
158
166
|
if (atTheApp({ focused: focused(), idleMs: idleMs() })) return seen();
|
|
167
|
+
let banked = false;
|
|
159
168
|
for (const a of arrivals) {
|
|
160
169
|
if (!a || !a.id || unseen.has(a.id)) continue;
|
|
161
170
|
unseen.set(a.id, a);
|
|
171
|
+
toldAt.set(a.id, Date.now());
|
|
172
|
+
banked = true;
|
|
162
173
|
}
|
|
174
|
+
if (banked) changed();
|
|
163
175
|
if (!unseen.size || timer) return;
|
|
164
176
|
timer = setTimeout(speak, coalesceMs);
|
|
165
177
|
},
|
|
166
178
|
// The window came forward. Whatever was on screen is now behind her.
|
|
167
179
|
seen,
|
|
180
|
+
// What it has told you about, or is about to, since you were last at the
|
|
181
|
+
// app, and when each arrived.
|
|
182
|
+
told: () => [...toldAt].map(([id, at]) => ({ id, at })),
|
|
168
183
|
// Tests, and nothing else.
|
|
169
184
|
_pending: () => [...unseen.values()],
|
|
170
185
|
_spoken: () => ({ spoke, spokeAsk }),
|
|
171
186
|
};
|
|
172
187
|
}
|
|
173
188
|
|
|
174
|
-
// `onArrivals` hears every arrival too,
|
|
175
|
-
// (main/phone-
|
|
176
|
-
|
|
189
|
+
// `onArrivals` hears every arrival too, with the inbox they arrived in, for
|
|
190
|
+
// the phone's alerts (main/phone-alerts.mjs), which make their own call.
|
|
191
|
+
const needsYouOf = (payload) => (Array.isArray(payload.needsYou) ? payload.needsYou : []);
|
|
192
|
+
export function installNotifier({ app, window, Notification, powerMonitor, ipcMain, nativeImage, onArrivals = () => {}, onChange = () => {} }) {
|
|
177
193
|
if (Notification.isSupported && !Notification.isSupported()) {
|
|
178
|
-
ipcMain.handle('zero:notify', (_e, payload = {}) => { try { onArrivals(Array.isArray(payload.arrivals) ? payload.arrivals : []); } catch {} });
|
|
179
|
-
return { add() {}, seen() {} };
|
|
194
|
+
ipcMain.handle('zero:notify', (_e, payload = {}) => { try { onArrivals(Array.isArray(payload.arrivals) ? payload.arrivals : [], needsYouOf(payload)); } catch {} });
|
|
195
|
+
return { add() {}, seen() {}, told: () => [] };
|
|
180
196
|
}
|
|
181
197
|
// app.getAppPath() is the repo from source and the asar inside the bundle
|
|
182
198
|
// when it is packaged, and the mark sits at the same place in both.
|
|
183
199
|
const icon = nativeImage
|
|
184
200
|
? markFor({ root: app.getAppPath(), createFromPath: (p) => nativeImage.createFromPath(p) })
|
|
185
201
|
: null;
|
|
186
|
-
const notifier = createNotifier({ window, Notification, powerMonitor, icon });
|
|
202
|
+
const notifier = createNotifier({ window, Notification, powerMonitor, icon, onChange });
|
|
187
203
|
window.on('focus', () => notifier.seen());
|
|
188
204
|
// Unlocking with Agentbox already the front app never fires 'focus' (it never
|
|
189
205
|
// lost it), so without this the banner that fired while the screen was
|
|
@@ -191,7 +207,7 @@ export function installNotifier({ app, window, Notification, powerMonitor, ipcMa
|
|
|
191
207
|
try { powerMonitor.on('unlock-screen', () => { if (window.isFocused()) notifier.seen(); }); } catch {}
|
|
192
208
|
ipcMain.handle('zero:notify', (_e, payload = {}) => {
|
|
193
209
|
const arrivals = Array.isArray(payload.arrivals) ? payload.arrivals : [];
|
|
194
|
-
try { onArrivals(arrivals); } catch {}
|
|
210
|
+
try { onArrivals(arrivals, needsYouOf(payload)); } catch {}
|
|
195
211
|
notifier.add(arrivals);
|
|
196
212
|
});
|
|
197
213
|
return notifier;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// ALERTS ON HER PHONE'S LOCK SCREEN, SENT BY THIS MAC.
|
|
2
|
+
//
|
|
3
|
+
// The arrivals are the desktop banner's (main/notify.mjs, from App.tsx's
|
|
4
|
+
// inbox), and so are the rules (shared/notify-rules.mjs): nothing while she
|
|
5
|
+
// has used this Mac in the last five minutes, once per stretch away, once
|
|
6
|
+
// more for a permission card. They go out from the phone pass, after it has
|
|
7
|
+
// published, so the thread an alert opens is already on the phone. The
|
|
8
|
+
// server-side sender in docs/mobile/PLAN.md, which alerts with every Mac
|
|
9
|
+
// shut, is a later phase.
|
|
10
|
+
//
|
|
11
|
+
// The words are the phone banner's (phoneBanner): the thread's title in bold,
|
|
12
|
+
// asked for on 2026-10-13 and again on 2026-10-23, and nothing else of it.
|
|
13
|
+
import { atTheComputer, shouldSpeak, phoneBanner } from '../shared/notify-rules.mjs';
|
|
14
|
+
|
|
15
|
+
export const EXPO_PUSH = 'https://exp.host/--/api/v2/push/send';
|
|
16
|
+
// Arrivals this close together are one alert.
|
|
17
|
+
export const FOLD_MS = 3000;
|
|
18
|
+
const DEADLINE_MS = 10_000;
|
|
19
|
+
const ALERTED_KEPT_MS = 30 * 24 * 3600_000;
|
|
20
|
+
|
|
21
|
+
// renderer/src/phone-rows.ts phoneSlug, which the phone opens a thread by.
|
|
22
|
+
const phoneSlugOf = (device, project) => `${project}@${device}`;
|
|
23
|
+
const stampOf = (item) => `${item.status ?? ''}:${item.wrote?.status?.ts ?? item.updatedAt ?? ''}`;
|
|
24
|
+
|
|
25
|
+
/** One request for every phone; Expo answers a ticket for each, in order. */
|
|
26
|
+
export async function sendToExpo(messages, { fetchImpl = fetch, url = EXPO_PUSH, deadlineMs = DEADLINE_MS } = {}) {
|
|
27
|
+
const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(messages), signal: AbortSignal.timeout(deadlineMs) });
|
|
28
|
+
const said = await res.json().catch(() => null);
|
|
29
|
+
if (!res.ok || !Array.isArray(said?.data)) throw new Error(`Expo answered ${res.status}${said?.errors?.[0]?.message ? `: ${said.errors[0].message}` : ''}`);
|
|
30
|
+
return said.data;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createPhoneAlerts({ backend, owner, device, state, save, now, log, idleMs, send = sendToExpo, still = () => {} }) {
|
|
34
|
+
state.alerted ??= {};
|
|
35
|
+
// In memory, as the desktop's banner keeps them: an arrival is news once.
|
|
36
|
+
const queued = new Map();
|
|
37
|
+
let firstAt = 0;
|
|
38
|
+
let needsYou = [];
|
|
39
|
+
let spoke = false;
|
|
40
|
+
let spokeAsk = false;
|
|
41
|
+
const said = new Set();
|
|
42
|
+
const once = (kind, line) => { if (!said.has(kind)) log(line); said.add(kind); };
|
|
43
|
+
const away = () => { try { return !atTheComputer({ idleMs: idleMs() }); } catch { return true; } };
|
|
44
|
+
|
|
45
|
+
function seen() {
|
|
46
|
+
queued.clear();
|
|
47
|
+
spoke = false;
|
|
48
|
+
spokeAsk = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the desktop's notifier was told arrived, and the inbox it arrived in. */
|
|
52
|
+
function arrived(list, inbox = []) {
|
|
53
|
+
if (!Array.isArray(list) || !list.length) return;
|
|
54
|
+
if (!away()) return seen();
|
|
55
|
+
if (Array.isArray(inbox)) needsYou = inbox.filter((r) => r && typeof r.id === 'string' && typeof r.product === 'string');
|
|
56
|
+
for (const a of list) {
|
|
57
|
+
if (!a || typeof a.id !== 'string' || typeof a.product !== 'string') continue;
|
|
58
|
+
const ask = a.kind === 'approval';
|
|
59
|
+
const item = ask ? a.item : a.id;
|
|
60
|
+
if (typeof item !== 'string') continue;
|
|
61
|
+
if (!queued.size) firstAt = now();
|
|
62
|
+
queued.set(ask ? `ask:${a.id}` : `${a.product}:${a.id}`, { ask, product: a.product, item, title: a.title, thread: a.thread });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `carried(slug, id)` is the thread's row when the phone has it, else null.
|
|
67
|
+
async function pass(carried) {
|
|
68
|
+
if (!away()) return seen();
|
|
69
|
+
if (!queued.size || now() - firstAt < FOLD_MS) return;
|
|
70
|
+
const fresh = [];
|
|
71
|
+
for (const [key, q] of queued) {
|
|
72
|
+
const row = carried(q.product, q.item);
|
|
73
|
+
if (!row) continue;
|
|
74
|
+
const stamp = q.ask ? 'asked' : stampOf(row);
|
|
75
|
+
if (state.alerted[key]?.stamp !== stamp) fresh.push({ key, stamp, ...q, named: row.title });
|
|
76
|
+
}
|
|
77
|
+
queued.clear();
|
|
78
|
+
if (!fresh.length || !shouldSpeak({ spoke, spokeAsk, waiting: fresh.map((f) => ({ kind: f.ask ? 'approval' : 'item' })) })) return;
|
|
79
|
+
still();
|
|
80
|
+
let phones;
|
|
81
|
+
try { phones = await backend.pushDevices(owner); } catch (err) { once('read', `phone: could not read the phones to alert: ${err?.message ?? err}`); return; }
|
|
82
|
+
if (!phones.length) return;
|
|
83
|
+
const threads = [...new Set(fresh.map((f) => `${f.product}\0${f.item}`))];
|
|
84
|
+
const [product, item] = threads[0].split('\0');
|
|
85
|
+
const badge = new Set(needsYou.filter((r) => carried(r.product, r.id)).map((r) => `${r.product}\0${r.id}`)).size;
|
|
86
|
+
const data = threads.length === 1 ? { product: phoneSlugOf(device, product), id: item } : {};
|
|
87
|
+
const say = phoneBanner(fresh.map((f) => (f.ask
|
|
88
|
+
? { kind: 'approval', title: f.title, thread: f.thread || f.named }
|
|
89
|
+
: { kind: 'item', title: f.title || f.named })));
|
|
90
|
+
const messages = phones.map((p) => ({ to: p.token, title: say.title, body: say.body, sound: 'default', badge, priority: 'high', channelId: 'default', data }));
|
|
91
|
+
still();
|
|
92
|
+
let tickets;
|
|
93
|
+
try { tickets = await send(messages); } catch (err) { once('send', `phone: could not send an alert: ${err?.message ?? err}`); return; }
|
|
94
|
+
let reached = 0;
|
|
95
|
+
for (const [i, t] of (tickets ?? []).entries()) {
|
|
96
|
+
if (t?.status === 'ok') { reached += 1; continue; }
|
|
97
|
+
const why = t?.details?.error ?? t?.message ?? 'no answer';
|
|
98
|
+
if (why === 'DeviceNotRegistered') {
|
|
99
|
+
try { await backend.dropPushDevice(messages[i].to, owner); } catch (err) { once('drop', `phone: could not forget a phone that stopped taking alerts: ${err?.message ?? err}`); }
|
|
100
|
+
} else once(`ticket:${why}`, `phone: Expo did not take an alert: ${why}`);
|
|
101
|
+
}
|
|
102
|
+
if (!reached) return;
|
|
103
|
+
said.clear();
|
|
104
|
+
spoke = true;
|
|
105
|
+
if (fresh.some((f) => f.ask)) spokeAsk = true;
|
|
106
|
+
for (const f of fresh) state.alerted[f.key] = { stamp: f.stamp, at: now() };
|
|
107
|
+
for (const [k, v] of Object.entries(state.alerted)) if (!(now() - (v?.at ?? 0) < ALERTED_KEPT_MS)) delete state.alerted[k];
|
|
108
|
+
save();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
arrived,
|
|
113
|
+
pass,
|
|
114
|
+
/** She answered from her phone, so she has seen what it holds. */
|
|
115
|
+
phoneUsed: () => { spoke = false; spokeAsk = false; },
|
|
116
|
+
};
|
|
117
|
+
}
|