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
package/main/team/session.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
11
12
|
import { nameSlug } from '../../shared/product-name.mjs';
|
|
12
13
|
import { runningAsAFreshUser } from '../../shared/fresh-user-home.mjs';
|
|
13
14
|
import { supabaseBackend } from './supabase-backend.mjs';
|
|
@@ -52,31 +53,49 @@ export function teamConfigOnThisMac(home = os.homedir()) {
|
|
|
52
53
|
// points at another file from a checkout only, and is first because that is
|
|
53
54
|
// what the tests run on.
|
|
54
55
|
//
|
|
55
|
-
// AN INSTALLED BUILD
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
// tests/
|
|
56
|
+
// AN INSTALLED BUILD SIGNS IN ONLY TO THE CLOUD BAKED INTO IT at release
|
|
57
|
+
// (`bakedTeam` in its package.json), never to a key on the Mac: the first
|
|
58
|
+
// download opened on a team's sign-in page because its Mac kept one
|
|
59
|
+
// (tests/the-installed-app-is-the-single-person-app). The sign-in is for the
|
|
60
|
+
// phone; the team version shows only with Alpha features › Multiplayer on
|
|
61
|
+
// (tests/multiplayer-is-an-alpha-feature-in-the-installed-app).
|
|
61
62
|
// AND SO IS ⌘K's NEW-USER WINDOW (w-9f6975906c, 2026-10-05). It runs from her
|
|
62
63
|
// checkout, which holds a team key, so it opened on the same sign-in page:
|
|
63
64
|
// "when i run this command i got the login screen again ... this was the
|
|
64
65
|
// critical thing to fix". A new user is a new user of the single-person app.
|
|
66
|
+
// A FRESH COPY HANDED A TEAM FILE IS A SECOND PERSON ON THAT TEAM (2026-10-21):
|
|
67
|
+
// a power tester in her room. It loads that file and no other, so the ⌘K
|
|
68
|
+
// window, which is handed none, stays the single-person app.
|
|
65
69
|
export function loadCloudConfig(appDir, { packaged = isPackagedElectron(), home = os.homedir(), env = process.env } = {}) {
|
|
66
|
-
if (packaged
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
if (packaged) return bakedTeam(appDir);
|
|
71
|
+
if (runningAsAFreshUser(env) && !env.AGENTBOX_TEAM_CONFIG) return null;
|
|
72
|
+
const tries = runningAsAFreshUser(env) ? [env.AGENTBOX_TEAM_CONFIG] : [
|
|
73
|
+
env.AGENTBOX_TEAM_CONFIG,
|
|
69
74
|
path.join(appDir, 'cloud', 'team.config.json'),
|
|
70
75
|
teamConfigOnThisMac(home),
|
|
71
76
|
];
|
|
77
|
+
let found = null;
|
|
72
78
|
for (const file of tries) {
|
|
73
79
|
if (!file) continue;
|
|
74
80
|
try {
|
|
75
81
|
const c = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
76
|
-
if (typeof c.url
|
|
82
|
+
if (typeof c.url !== 'string' || typeof c.anonKey !== 'string') continue;
|
|
83
|
+
// A field the winning file lacks (phoneSite, added later) comes from a
|
|
84
|
+
// file further down for the same cloud, never from another cloud's.
|
|
85
|
+
if (!found) found = c;
|
|
86
|
+
else if (c.url === found.url) found = { ...c, ...found };
|
|
77
87
|
} catch { /* try the next place; none of them is the single-person app */ }
|
|
78
88
|
}
|
|
79
|
-
return
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function bakedTeam(appDir) {
|
|
93
|
+
try {
|
|
94
|
+
const b = JSON.parse(fs.readFileSync(path.join(appDir, 'package.json'), 'utf8')).bakedTeam;
|
|
95
|
+
if (typeof b?.url !== 'string' || typeof b.anonKey !== 'string') return null;
|
|
96
|
+
const site = typeof b.phoneSite === 'string' && /^https:\/\/[^\s/]+(\/\S*)?$/.test(b.phoneSite) ? { phoneSite: b.phoneSite } : {};
|
|
97
|
+
return { url: b.url, anonKey: b.anonKey, ...site };
|
|
98
|
+
} catch { return null; }
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
function fileStorage(file, { encrypt, decrypt }) {
|
|
@@ -97,10 +116,18 @@ function fileStorage(file, { encrypt, decrypt }) {
|
|
|
97
116
|
return {
|
|
98
117
|
getItem: (key) => read()[key] ?? null,
|
|
99
118
|
setItem: (key, value) => { const d = read(); d[key] = value; write(d); },
|
|
100
|
-
|
|
119
|
+
// Nothing to remove writes nothing, so a Mac never signed in never asks the keychain.
|
|
120
|
+
removeItem: (key) => { const d = read(); if (!(key in d)) return; delete d[key]; write(d); },
|
|
101
121
|
};
|
|
102
122
|
}
|
|
103
123
|
|
|
124
|
+
// A request to the account database that never answers is ended: its
|
|
125
|
+
// library waits for ever, and the phone's and the team's sync wait on it.
|
|
126
|
+
// Long enough for a file going up on a slow network.
|
|
127
|
+
const DATABASE_DEADLINE_MS = 5 * 60_000;
|
|
128
|
+
export const withDeadline = (ms = DATABASE_DEADLINE_MS) => (url, init = {}) =>
|
|
129
|
+
fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(ms)]) : AbortSignal.timeout(ms) });
|
|
130
|
+
|
|
104
131
|
export function supabaseSession({ cloudConfig, sessionFile, encrypt = null, decrypt = null, openExternal, packaged = isPackagedElectron(), testLogin = process.env.AGENTBOX_TEAM_TEST_LOGIN }) {
|
|
105
132
|
const configured = !!cloudConfig;
|
|
106
133
|
if (packaged) testLogin = null;
|
|
@@ -110,6 +137,7 @@ export function supabaseSession({ cloudConfig, sessionFile, encrypt = null, decr
|
|
|
110
137
|
if (client) return client;
|
|
111
138
|
const { createClient } = await import('@supabase/supabase-js');
|
|
112
139
|
client = createClient(cloudConfig.url, cloudConfig.anonKey, {
|
|
140
|
+
global: { fetch: withDeadline() },
|
|
113
141
|
auth: {
|
|
114
142
|
flowType: 'pkce',
|
|
115
143
|
storage: fileStorage(sessionFile, { encrypt, decrypt }),
|
|
@@ -163,14 +191,28 @@ export function supabaseSession({ cloudConfig, sessionFile, encrypt = null, decr
|
|
|
163
191
|
// answers { confirm: true } and the page says to check the inbox.
|
|
164
192
|
async signUp({ email, password, name }) {
|
|
165
193
|
if (!configured) throw new Error('this build has no team cloud configured');
|
|
166
|
-
|
|
167
|
-
|
|
194
|
+
// The plain kind of email link, which the phone website can finish; this
|
|
195
|
+
// Mac's own client would ask for a code only this Mac could spend.
|
|
196
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
197
|
+
const plain = createClient(cloudConfig.url, cloudConfig.anonKey, {
|
|
198
|
+
global: { fetch: withDeadline() },
|
|
199
|
+
auth: { flowType: 'implicit', persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
200
|
+
});
|
|
201
|
+
const { data, error } = await plain.auth.signUp({
|
|
168
202
|
email: String(email ?? '').trim(),
|
|
169
203
|
password: String(password ?? ''),
|
|
170
|
-
options: {
|
|
204
|
+
options: {
|
|
205
|
+
data: { full_name: String(name ?? '').trim() || undefined },
|
|
206
|
+
// Else the email's link goes to the database's default, this Mac's sign-in listener, dead outside a sign-in.
|
|
207
|
+
emailRedirectTo: typeof cloudConfig.phoneSite === 'string' ? cloudConfig.phoneSite : undefined,
|
|
208
|
+
},
|
|
171
209
|
});
|
|
172
210
|
if (error) throw new Error(plainAuthError(error.message));
|
|
173
|
-
|
|
211
|
+
if (!data?.session) return { confirm: true };
|
|
212
|
+
const c = await getClient();
|
|
213
|
+
const { error: kept } = await c.auth.setSession(data.session);
|
|
214
|
+
if (kept) throw new Error(plainAuthError(kept.message));
|
|
215
|
+
return { backend: supabaseBackend(c) };
|
|
174
216
|
},
|
|
175
217
|
|
|
176
218
|
cancelSignIn: () => cancelGoogleSignIn(),
|
|
@@ -194,23 +236,51 @@ export function plainAuthError(message) {
|
|
|
194
236
|
return m || 'That did not work. Try again.';
|
|
195
237
|
}
|
|
196
238
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
239
|
+
// THE SIGN-IN NEVER LANDS IN THE STORE ROOT. The store is in every worker's
|
|
240
|
+
// granted directories, and this file holds a refresh token in plain JSON, so it
|
|
241
|
+
// resolves to a path OUTSIDE the store. A dir inside the store moves to a
|
|
242
|
+
// per-store folder under the config home; if even that is inside the store
|
|
243
|
+
// there is nowhere safe and it throws, because writing the token where a worker
|
|
244
|
+
// reads it is worse than a sign-in to do again. A copy an older version left in
|
|
245
|
+
// the store, under either name, is carried across.
|
|
246
|
+
const isInside = (file, root) => {
|
|
247
|
+
const f = path.resolve(file);
|
|
248
|
+
const r = path.resolve(root);
|
|
249
|
+
return f === r || f.startsWith(r + path.sep);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
function configHome({ home, env }) {
|
|
253
|
+
const given = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
|
|
254
|
+
return given && path.isAbsolute(given) ? given : path.join(home, '.config');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function sessionFileOutsideStore({ dir, name, storeRoot, home = os.homedir(), env = process.env }) {
|
|
258
|
+
let file = path.join(dir, name);
|
|
259
|
+
if (isInside(file, storeRoot)) {
|
|
260
|
+
const tag = crypto.createHash('sha256').update(path.resolve(storeRoot)).digest('hex').slice(0, 16);
|
|
261
|
+
file = path.join(configHome({ home, env }), nameSlug, 'sessions', tag, name);
|
|
262
|
+
if (isInside(file, storeRoot)) throw new Error(`no place outside the store root for the team session: ${storeRoot}`);
|
|
263
|
+
}
|
|
264
|
+
const olds = [path.join(storeRoot, '.team-session'), path.join(storeRoot, 'team-session.json')];
|
|
204
265
|
try {
|
|
205
|
-
if (!fs.existsSync(file)
|
|
206
|
-
|
|
207
|
-
|
|
266
|
+
if (!fs.existsSync(file)) {
|
|
267
|
+
for (const old of olds) {
|
|
268
|
+
if (path.resolve(old) === path.resolve(file) || !fs.existsSync(old)) continue;
|
|
269
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
270
|
+
fs.renameSync(old, file);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
208
273
|
}
|
|
209
274
|
if (fs.existsSync(file)) fs.chmodSync(file, 0o600);
|
|
210
275
|
} catch { /* a session that cannot be moved is a sign-in to do again */ }
|
|
211
276
|
return file;
|
|
212
277
|
}
|
|
213
278
|
|
|
279
|
+
// The headless app keeps its sign-in as plain JSON readable by this user only.
|
|
280
|
+
export function headlessSessionFile({ userDir, storeRoot, home, env }) {
|
|
281
|
+
return sessionFileOutsideStore({ dir: userDir, name: 'team-session.json', storeRoot, home, env });
|
|
282
|
+
}
|
|
283
|
+
|
|
214
284
|
// The same session shape over the in-memory cloud, for tests.
|
|
215
285
|
export function memorySession(backendFor) {
|
|
216
286
|
let signedIn = null;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// actually keeps people apart; nothing here is trusted to.
|
|
5
5
|
import { asPulled } from '../../shared/team-rules.mjs';
|
|
6
6
|
import { cleanStatus } from '../../shared/team-status.mjs';
|
|
7
|
+
import { roomsSupabase } from './rooms-supabase.mjs';
|
|
7
8
|
|
|
8
9
|
const PAGE = 500;
|
|
9
10
|
const PUSH_BATCH = 200;
|
|
@@ -19,8 +20,26 @@ function must({ data, error }, what) {
|
|
|
19
20
|
return data;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
const COMMAND_COLUMNS = 'id,owner,device,project,item,kind,payload,created_at,claimed_at,taken_at,outcome';
|
|
24
|
+
// Who claimed (`cloud:<run>`, 20261026000000_phone_carries_on_in_the_cloud.sql),
|
|
25
|
+
// asked for only once the database has the column, so a computer that runs
|
|
26
|
+
// before the migration is applied still reads its phone.
|
|
27
|
+
const CLOUD_COLUMNS = `${COMMAND_COLUMNS},claimed_by`;
|
|
28
|
+
const noCloudColumn = (error) => /claimed_by|column .*cloud|cloud.* column/i.test(String(error?.message ?? ''));
|
|
29
|
+
const commandOut = (r) => ({ id: r.id, owner: r.owner, device: r.device, project: r.project, item: r.item, kind: r.kind, payload: r.payload ?? {}, createdAt: r.created_at, claimedAt: r.claimed_at ?? null, claimedBy: r.claimed_by ?? null, takenAt: r.taken_at ?? null, outcome: r.outcome ?? null });
|
|
30
|
+
|
|
22
31
|
export function supabaseBackend(client) {
|
|
23
32
|
let personId = null;
|
|
33
|
+
// Whether the database has the cloud's columns yet; the first refusal says not.
|
|
34
|
+
let cloudColumns = true;
|
|
35
|
+
const commandsWith = async (query, base = COMMAND_COLUMNS) => {
|
|
36
|
+
if (cloudColumns) {
|
|
37
|
+
const out = await query(`${base},claimed_by`);
|
|
38
|
+
if (!out.error || !noCloudColumn(out.error)) return out;
|
|
39
|
+
cloudColumns = false;
|
|
40
|
+
}
|
|
41
|
+
return query(base);
|
|
42
|
+
};
|
|
24
43
|
async function myId() {
|
|
25
44
|
if (personId) return personId;
|
|
26
45
|
const { data, error } = await client.auth.getUser();
|
|
@@ -219,13 +238,174 @@ export function supabaseBackend(client) {
|
|
|
219
238
|
}));
|
|
220
239
|
},
|
|
221
240
|
|
|
241
|
+
// THE FILES A CONVERSATION'S MESSAGES LINK TO (w-140ac855b1). Row level
|
|
242
|
+
// security decides who may read and add one (20261013000000_message_files.sql).
|
|
243
|
+
async hasFiles(projectId, names) {
|
|
244
|
+
if (!names.length) return [];
|
|
245
|
+
const rows = must(await client.from('message_files').select('name').eq('project_id', projectId).in('name', names), 'asking which pictures your team has');
|
|
246
|
+
return rows.map((r) => r.name);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async getFile(projectId, name) {
|
|
250
|
+
const rows = must(await client.from('message_files').select('data').eq('project_id', projectId).eq('name', name).limit(1), 'taking a picture a teammate sent');
|
|
251
|
+
return rows[0]?.data ?? null;
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async putFile(projectId, name, data) {
|
|
255
|
+
const me = await myId();
|
|
256
|
+
const rows = must(await client.from('message_files').upsert({ project_id: projectId, name, by_person: me, data }, { onConflict: 'project_id,name', ignoreDuplicates: true }).select('name'), 'sending a picture');
|
|
257
|
+
return (rows?.length ?? 0) > 0;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
// HER OWN ROWS FOR HER PHONE (20261014000200_phone_inbox.sql). The owner
|
|
261
|
+
// column defaults to her, and row level security keeps every row hers.
|
|
262
|
+
// Every row names the computer it belongs to (`device`).
|
|
263
|
+
// Each write names who it is for, so a write begun before a sign-out
|
|
264
|
+
// that lands after somebody else signed in is refused by the database
|
|
265
|
+
// (`owner = auth.uid()`) rather than written into their account.
|
|
266
|
+
async putPhoneProject({ owner, device, project, name, cloud }) {
|
|
267
|
+
await myId();
|
|
268
|
+
const row = { ...(owner ? { owner } : {}), device, project, name, updated_at: new Date().toISOString() };
|
|
269
|
+
const put = (r) => client.from('phone_projects').upsert(r, { onConflict: 'owner,device,project' });
|
|
270
|
+
let out = cloud === undefined || !cloudColumns ? await put(row) : await put({ ...row, cloud: !!cloud });
|
|
271
|
+
if (out.error && cloud !== undefined && cloudColumns && noCloudColumn(out.error)) { cloudColumns = false; out = await put(row); }
|
|
272
|
+
must(out, 'putting a project on your phone');
|
|
273
|
+
},
|
|
274
|
+
async dropPhoneProject(device, project, owner) {
|
|
275
|
+
const me = await myId();
|
|
276
|
+
// Its threads go with it (the database cascades).
|
|
277
|
+
must(await client.from('phone_projects').delete().eq('owner', owner ?? me).eq('device', device).eq('project', project), 'taking a project off your phone');
|
|
278
|
+
},
|
|
279
|
+
async putPhoneThreads(device, project, rows, owner) {
|
|
280
|
+
await myId();
|
|
281
|
+
const at = new Date().toISOString();
|
|
282
|
+
for (let i = 0; i < rows.length; i += 50) {
|
|
283
|
+
const batch = rows.slice(i, i + 50).map((r) => ({ ...(owner ? { owner } : {}), device, project, item: r.item, snapshot: r.snapshot, updated_at: at }));
|
|
284
|
+
must(await client.from('phone_threads').upsert(batch, { onConflict: 'owner,device,project,item' }), 'sending threads to your phone');
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
// Her files for the phone, in her own folder of a private bucket
|
|
288
|
+
// (20261017000000_phone_files.sql). Named by what is in them, so one
|
|
289
|
+
// already there is the same file and is left as it is.
|
|
290
|
+
async putPhoneFile({ owner, device, name, data, type }) {
|
|
291
|
+
const me = await myId();
|
|
292
|
+
const { error } = await client.storage.from('phone-files').upload(`${owner ?? me}/${device}/${name}`, data, { contentType: type ?? 'application/octet-stream', upsert: false });
|
|
293
|
+
if (error && !/exists|duplicate/i.test(`${error.message} ${error.statusCode ?? ''}`) && String(error.statusCode) !== '409') throw new Error(`sending a file to your phone: ${error.message}`);
|
|
294
|
+
},
|
|
295
|
+
// A picture her phone put in this computer's folder for a reply; null when it is not there.
|
|
296
|
+
async getPhoneFile({ owner, device, name }) {
|
|
297
|
+
const me = await myId();
|
|
298
|
+
const { data, error } = await client.storage.from('phone-files').download(`${owner ?? me}/${device}/${name}`);
|
|
299
|
+
if (error) {
|
|
300
|
+
if (/not.?found/i.test(`${error.message} ${error.error ?? ''}`) || [400, 404].includes(Number(error.statusCode ?? error.status))) return null;
|
|
301
|
+
throw new Error(`taking a picture from your phone: ${error.message}`);
|
|
302
|
+
}
|
|
303
|
+
return Buffer.from(await data.arrayBuffer());
|
|
304
|
+
},
|
|
305
|
+
async dropPhoneFiles({ owner, device, names }) {
|
|
306
|
+
const me = await myId();
|
|
307
|
+
for (let i = 0; i < names.length; i += 100) {
|
|
308
|
+
const { error } = await client.storage.from('phone-files').remove(names.slice(i, i + 100).map((n) => `${owner ?? me}/${device}/${n}`));
|
|
309
|
+
if (error) throw new Error(`clearing old files from your phone: ${error.message}`);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
async dropPhoneThreads(device, project, items, owner) {
|
|
313
|
+
const me = await myId();
|
|
314
|
+
for (let i = 0; i < items.length; i += 100) {
|
|
315
|
+
must(await client.from('phone_threads').delete().eq('owner', owner ?? me).eq('device', device).eq('project', project).in('item', items.slice(i, i + 100)), 'taking threads off your phone');
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
// What her phone reads and sends.
|
|
319
|
+
async phoneProjects() {
|
|
320
|
+
const me = await myId();
|
|
321
|
+
const rows = must(await client.from('phone_projects').select('owner,device,project,name').order('name'), 'reading the projects on your phone');
|
|
322
|
+
return (rows ?? []).filter((r) => r.owner === me);
|
|
323
|
+
},
|
|
324
|
+
async phoneThreads() {
|
|
325
|
+
const me = await myId();
|
|
326
|
+
const rows = must(await client.from('phone_threads').select('owner,device,project,item,snapshot,updated_at').order('updated_at', { ascending: false }).limit(1000), 'reading the threads on your phone');
|
|
327
|
+
return (rows ?? []).filter((r) => r.owner === me).map((r) => ({ owner: r.owner, device: r.device, project: r.project, item: r.item, snapshot: r.snapshot, updatedAt: Date.parse(r.updated_at) }));
|
|
328
|
+
},
|
|
329
|
+
// One row per computer (20261023000000_phone_settings.sql).
|
|
330
|
+
async putPhoneSettings({ owner, device, name, settings }) {
|
|
331
|
+
await myId();
|
|
332
|
+
must(await client.from('phone_settings').upsert({ ...(owner ? { owner } : {}), device, name, settings, updated_at: new Date().toISOString() }, { onConflict: 'owner,device' }), 'sending your settings to your phone');
|
|
333
|
+
},
|
|
334
|
+
async phoneSettings() {
|
|
335
|
+
const me = await myId();
|
|
336
|
+
const rows = must(await client.from('phone_settings').select('owner,device,name,settings,updated_at').order('updated_at', { ascending: false }), 'reading your computer\'s settings');
|
|
337
|
+
return (rows ?? []).filter((r) => r.owner === me).map((r) => ({ owner: r.owner, device: r.device, name: r.name, settings: r.settings, updatedAt: Date.parse(r.updated_at) }));
|
|
338
|
+
},
|
|
339
|
+
// The phones her alerts go to (20261025000000_push_devices.sql). The phone writes its own row; this is for tests.
|
|
340
|
+
async putPushDevice({ token, platform, appVersion = null }) {
|
|
341
|
+
await myId();
|
|
342
|
+
must(await client.from('push_devices').upsert({ token, platform, app_version: appVersion, updated_at: new Date().toISOString() }, { onConflict: 'owner,token' }), 'turning on alerts');
|
|
343
|
+
},
|
|
344
|
+
async pushDevices(owner) {
|
|
345
|
+
const me = await myId();
|
|
346
|
+
const rows = must(await client.from('push_devices').select('owner,token,platform,app_version,updated_at').eq('owner', owner ?? me), 'reading the phones to alert');
|
|
347
|
+
return (rows ?? []).map((r) => ({ owner: r.owner, token: r.token, platform: r.platform, appVersion: r.app_version ?? null, updatedAt: Date.parse(r.updated_at) }));
|
|
348
|
+
},
|
|
349
|
+
async dropPushDevice(token, owner) {
|
|
350
|
+
const me = await myId();
|
|
351
|
+
must(await client.from('push_devices').delete().eq('owner', owner ?? me).eq('token', token), 'forgetting a phone');
|
|
352
|
+
},
|
|
353
|
+
async addPhoneCommand({ device, project = null, item = null, kind, payload = {} }) {
|
|
354
|
+
await myId();
|
|
355
|
+
const row = must(await client.from('phone_commands').insert({ device, project, item, kind, payload }).select(COMMAND_COLUMNS).single(), 'sending that to your computer');
|
|
356
|
+
return commandOut(row);
|
|
357
|
+
},
|
|
358
|
+
async phoneCommands() {
|
|
359
|
+
await myId();
|
|
360
|
+
const rows = must(await client.from('phone_commands').select(COMMAND_COLUMNS).order('created_at', { ascending: true }).limit(500), 'reading what your phone sent');
|
|
361
|
+
return (rows ?? []).map(commandOut);
|
|
362
|
+
},
|
|
363
|
+
// This computer's, every one still waiting, oldest first, a page at a time.
|
|
364
|
+
async waitingPhoneCommands(device) {
|
|
365
|
+
await myId();
|
|
366
|
+
const out = [];
|
|
367
|
+
for (let from = 0; ; from += 200) {
|
|
368
|
+
const rows = must(await commandsWith((cols) => client.from('phone_commands').select(cols).eq('device', device).is('taken_at', null).order('created_at', { ascending: true }).range(from, from + 199)), 'reading what your phone sent');
|
|
369
|
+
out.push(...(rows ?? []).map(commandOut));
|
|
370
|
+
if (!rows || rows.length < 200) return out;
|
|
371
|
+
}
|
|
372
|
+
},
|
|
373
|
+
// This computer's commands the cloud claimed since `since`, taken or not,
|
|
374
|
+
// so a computer waking up joins the runs it started (Supervisor#adoptCloudRun).
|
|
375
|
+
async cloudPhoneCommands(device, since) {
|
|
376
|
+
await myId();
|
|
377
|
+
if (!cloudColumns) return [];
|
|
378
|
+
const out = await client.from('phone_commands').select(CLOUD_COLUMNS).eq('device', device).not('claimed_by', 'is', null).gte('created_at', since).order('created_at', { ascending: true }).limit(200);
|
|
379
|
+
if (out.error && noCloudColumn(out.error)) { cloudColumns = false; return []; }
|
|
380
|
+
return (must(out, 'reading what the cloud took from your phone') ?? []).map(commandOut);
|
|
381
|
+
},
|
|
382
|
+
// THE COMPUTER CLAIMS ONE BEFORE IT DOES IT; the phone takes one back only
|
|
383
|
+
// while nobody has (renderer/src/cloud-bridge.ts). The database lets one win.
|
|
384
|
+
async claimPhoneCommand(id, owner) {
|
|
385
|
+
const me = await myId();
|
|
386
|
+
const who = owner ?? me;
|
|
387
|
+
const won = must(await client.from('phone_commands').update({ claimed_at: new Date().toISOString() }).eq('id', id).eq('owner', who).is('taken_at', null).is('claimed_at', null).select('id'), 'starting what your phone sent');
|
|
388
|
+
if (won?.length) return 'claimed';
|
|
389
|
+
const row = must(await commandsWith((cols) => client.from('phone_commands').select(cols).eq('id', id).eq('owner', who).maybeSingle(), 'taken_at,claimed_at'), 'reading what your phone sent');
|
|
390
|
+
// The cloud's claim is the cloud's run: never done here as if it were ours.
|
|
391
|
+
if (row?.claimed_by) return 'cloud';
|
|
392
|
+
return row && !row.taken_at && row.claimed_at ? 'ours' : 'gone';
|
|
393
|
+
},
|
|
394
|
+
async markPhoneCommand(id, outcome, owner) {
|
|
395
|
+
const me = await myId();
|
|
396
|
+
must(await client.from('phone_commands').update({ taken_at: new Date().toISOString(), outcome }).eq('id', id).eq('owner', owner ?? me).is('taken_at', null), 'marking what your phone sent as done');
|
|
397
|
+
},
|
|
398
|
+
|
|
222
399
|
subscribe(onChange) {
|
|
223
400
|
const channel = client.channel(`team-${Math.random().toString(36).slice(2)}`)
|
|
401
|
+
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'phone_commands' }, () => onChange())
|
|
224
402
|
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'lines' }, () => onChange())
|
|
225
403
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'thread_cards' }, () => onChange())
|
|
226
404
|
.on('postgres_changes', { event: 'UPDATE', schema: 'public', table: 'people' }, () => onChange())
|
|
227
405
|
.subscribe();
|
|
228
406
|
return () => { client.removeChannel(channel); };
|
|
229
407
|
},
|
|
408
|
+
|
|
409
|
+
...roomsSupabase(client, myId, must),
|
|
230
410
|
};
|
|
231
411
|
}
|
package/main/team/sync.mjs
CHANGED
|
@@ -20,9 +20,13 @@
|
|
|
20
20
|
// 4. PRIVATE WORK. One title-free line per open task in a private project
|
|
21
21
|
// (who, what state, when it moved), and only when that set changed.
|
|
22
22
|
//
|
|
23
|
+
// And in a conversation, the files its messages link to (below, FILES).
|
|
24
|
+
//
|
|
23
25
|
// Everything outside this file is passed in, so two "Macs" can run in one test
|
|
24
26
|
// over an in-memory cloud. The app wires the real ones in main/team/index.mjs.
|
|
25
27
|
import crypto from 'node:crypto';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
26
30
|
import { whatATeammateMaySet, whatYourOtherCopyMaySet } from '../../shared/team-rules.mjs';
|
|
27
31
|
|
|
28
32
|
const PAGE = 500;
|
|
@@ -31,6 +35,49 @@ const PAGE = 500;
|
|
|
31
35
|
// joins no more, so a teammate cannot fill this disk with folders.
|
|
32
36
|
export const MAX_SHARED_PROJECTS = 50;
|
|
33
37
|
|
|
38
|
+
// FILES: A PICTURE IN A MESSAGE GOES WITH IT (w-140ac855b1, 2026-10-13).
|
|
39
|
+
//
|
|
40
|
+
// A picture pasted into a message is saved in the project's attachments folder
|
|
41
|
+
// and the message links to it, ``. Only the line used to
|
|
42
|
+
// cross, so every picture sent to a teammate arrived as "missing image". Now
|
|
43
|
+
// the sender's Mac puts each file it links to in the cloud BEFORE pushing the
|
|
44
|
+
// line, and the reader's Mac takes the file down BEFORE the line lands, so a
|
|
45
|
+
// message is never drawn without its picture (a picture drawn as missing stays
|
|
46
|
+
// missing until the thread is reopened).
|
|
47
|
+
//
|
|
48
|
+
// Only in a conversation: an ordinary shared project's words never cross
|
|
49
|
+
// (shared/team-rules.mjs), so nothing they link to does either. And every file
|
|
50
|
+
// call is best effort. A cloud without the table, or one file that will not
|
|
51
|
+
// come, must never stop a message.
|
|
52
|
+
export const MAX_MESSAGE_FILE_BYTES = 10 * 1024 * 1024;
|
|
53
|
+
// A file named in a message the cloud does not have yet, which is a sender who
|
|
54
|
+
// has not updated. Asked about again at most once a minute, for two weeks.
|
|
55
|
+
const WANTED_MAX = 200;
|
|
56
|
+
const WANTED_EVERY_MS = 60_000;
|
|
57
|
+
const WANTED_FOR_MS = 14 * 24 * 60 * 60 * 1000;
|
|
58
|
+
// A PLAIN NAME and nothing else: no slash, no leading dot. The reader writes
|
|
59
|
+
// what a teammate's line names, so a name is all a line may hand it.
|
|
60
|
+
const NAMED = /\]\(attachments\/([A-Za-z0-9][\w.-]{0,159})\)/g;
|
|
61
|
+
|
|
62
|
+
/** The files a ledger line's message links to, by name, each once. */
|
|
63
|
+
export function filesNamedIn(line) {
|
|
64
|
+
if (!line || typeof line !== 'object' || !line.patch) return [];
|
|
65
|
+
return [...new Set([...JSON.stringify(line.patch).matchAll(NAMED)].map((m) => m[1]))];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The attachments folder of a project on this Mac.
|
|
69
|
+
export const diskFiles = {
|
|
70
|
+
size(dir, name) {
|
|
71
|
+
try { const st = fs.statSync(path.join(dir, 'attachments', name)); return st.isFile() ? st.size : null; } catch { return null; }
|
|
72
|
+
},
|
|
73
|
+
read: (dir, name) => fs.readFileSync(path.join(dir, 'attachments', name)),
|
|
74
|
+
// `wx`: a file already here under that name is never replaced.
|
|
75
|
+
write(dir, name, bytes) {
|
|
76
|
+
fs.mkdirSync(path.join(dir, 'attachments'), { recursive: true });
|
|
77
|
+
try { fs.writeFileSync(path.join(dir, 'attachments', name), bytes, { flag: 'wx' }); } catch (err) { if (err.code !== 'EEXIST') throw err; }
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
34
81
|
// Where each shared project's cursors stand, kept between runs.
|
|
35
82
|
export function memorySyncState() {
|
|
36
83
|
const map = new Map();
|
|
@@ -78,8 +125,65 @@ function adopt(line, me) {
|
|
|
78
125
|
return { ...line, by: line.by || me, uid };
|
|
79
126
|
}
|
|
80
127
|
|
|
81
|
-
export function createTeamSync({ backend, disk, state, listShared, joinProject, listCards = () => [], teamIdOf = () => null }) {
|
|
128
|
+
export function createTeamSync({ backend, disk, state, listShared, joinProject, listCards = () => [], teamIdOf = () => null, files = diskFiles, now = Date.now, log = () => {} }) {
|
|
82
129
|
let running = null;
|
|
130
|
+
const quietly = async (what, fn, fallback) => {
|
|
131
|
+
try { return await fn(); } catch (err) { log(`team files: ${what}: ${err?.message ?? err}`); return fallback; }
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Takes down whichever of `names` the cloud has and this Mac does not.
|
|
135
|
+
// Answers the names still missing.
|
|
136
|
+
async function takeFiles(project, names) {
|
|
137
|
+
const missing = [...new Set(names)].filter((n) => files.size(project.dir, n) == null);
|
|
138
|
+
if (!missing.length || !backend.hasFiles) return missing;
|
|
139
|
+
const there = new Set(await quietly('asking', () => backend.hasFiles(project.projectId, missing), []));
|
|
140
|
+
for (const name of missing.filter((n) => there.has(n))) {
|
|
141
|
+
// One at a time: each can be ten megabytes.
|
|
142
|
+
const data = await quietly(`taking ${name}`, () => backend.getFile(project.projectId, name), null);
|
|
143
|
+
const bytes = typeof data === 'string' ? Buffer.from(data, 'base64') : null;
|
|
144
|
+
if (bytes && bytes.length <= MAX_MESSAGE_FILE_BYTES) await quietly(`saving ${name}`, () => files.write(project.dir, name, bytes));
|
|
145
|
+
}
|
|
146
|
+
return missing.filter((n) => files.size(project.dir, n) == null);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Every line written here since the last look: the files your messages name
|
|
150
|
+
// go up, and the files anyone's name that are not here are wanted. The first
|
|
151
|
+
// look after updating starts at the top of the ledger, which is what sends a
|
|
152
|
+
// picture from before this existed.
|
|
153
|
+
async function sendFiles(project, me) {
|
|
154
|
+
if (!backend.putFile) return;
|
|
155
|
+
const cursor = state.get(project.projectId);
|
|
156
|
+
const { lines, end } = disk.readLinesFrom(project.dir, cursor.filesFrom ?? 0);
|
|
157
|
+
const mine = new Set();
|
|
158
|
+
const wanted = new Map((cursor.wanted ?? []).map((w) => [w.name, w]));
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
for (const name of filesNamedIn(line)) {
|
|
161
|
+
const size = files.size(project.dir, name);
|
|
162
|
+
if (size == null) { if (!wanted.has(name)) wanted.set(name, { name, since: now(), checked: 0 }); }
|
|
163
|
+
else if ((!line.by || line.by === me) && size <= MAX_MESSAGE_FILE_BYTES) mine.add(name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (mine.size) {
|
|
167
|
+
const there = await quietly('asking', () => backend.hasFiles(project.projectId, [...mine]), null);
|
|
168
|
+
if (!there) return; // Not now; this stretch of the ledger is looked at again next pass.
|
|
169
|
+
for (const name of [...mine].filter((n) => !there.includes(n))) {
|
|
170
|
+
const sent = await quietly(`sending ${name}`, async () => { await backend.putFile(project.projectId, name, files.read(project.dir, name).toString('base64')); return true; }, false);
|
|
171
|
+
if (!sent) return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
state.set(project.projectId, { ...state.get(project.projectId), filesFrom: end, wanted: [...wanted.values()].slice(-WANTED_MAX) });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function takeWanted(project) {
|
|
178
|
+
const cursor = state.get(project.projectId);
|
|
179
|
+
const at = now();
|
|
180
|
+
const wanted = (cursor.wanted ?? []).filter((w) => at - w.since < WANTED_FOR_MS && files.size(project.dir, w.name) == null);
|
|
181
|
+
const due = wanted.filter((w) => at - w.checked >= WANTED_EVERY_MS).map((w) => w.name);
|
|
182
|
+
const still = new Set(due.length ? await takeFiles(project, due) : []);
|
|
183
|
+
const kept = wanted.filter((w) => !due.includes(w.name) || still.has(w.name))
|
|
184
|
+
.map((w) => (due.includes(w.name) ? { ...w, checked: at } : w));
|
|
185
|
+
if (kept.length !== (cursor.wanted ?? []).length || due.length) state.set(project.projectId, { ...state.get(project.projectId), wanted: kept });
|
|
186
|
+
}
|
|
83
187
|
|
|
84
188
|
async function pushProject(project, me) {
|
|
85
189
|
const cursor = state.get(project.projectId);
|
|
@@ -103,6 +207,8 @@ export function createTeamSync({ backend, disk, state, listShared, joinProject,
|
|
|
103
207
|
// teammate's keeps only what they may set here (shared/team-rules.mjs).
|
|
104
208
|
.map((l) => (l.by === me ? whatYourOtherCopyMaySet(l, { direct }) : whatATeammateMaySet(l, { direct })))
|
|
105
209
|
.filter(Boolean);
|
|
210
|
+
// The pictures first, so no message lands without its own.
|
|
211
|
+
if (direct) await takeFiles(project, bring.flatMap(filesNamedIn));
|
|
106
212
|
pulled += disk.appendForeignLines(project.dir, bring);
|
|
107
213
|
// Lines pulled in move the file's end, but they were never ours to push,
|
|
108
214
|
// and a push cursor left behind them would only re-read and skip them.
|
|
@@ -143,8 +249,10 @@ export function createTeamSync({ backend, disk, state, listShared, joinProject,
|
|
|
143
249
|
for (const project of cloudProjects) {
|
|
144
250
|
const here = local.get(project.id);
|
|
145
251
|
if (!here) continue;
|
|
252
|
+
if (here.direct === true) await sendFiles(here, me);
|
|
146
253
|
report.pushed += await pushProject(here, me);
|
|
147
254
|
report.pulled += await pullProject(here, me);
|
|
255
|
+
if (here.direct === true) await takeWanted(here);
|
|
148
256
|
}
|
|
149
257
|
|
|
150
258
|
report.cards = await publishCards();
|