agentbox-app 0.1.7 → 0.1.11
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 +209 -55
- package/bin/agentbox.mjs +6 -0
- package/briefs/message-rules.md +13 -3
- package/briefs/worker.md +63 -49
- package/briefs/writing-rules.md +90 -131
- package/main/account-discovery.mjs +21 -20
- package/main/account-tooling.mjs +9 -10
- package/main/agent-activity.mjs +45 -5
- package/main/agent-schedule.mjs +14 -14
- package/main/agent-sessions.mjs +94 -10
- package/main/agent-updates.mjs +114 -0
- package/main/agents.mjs +52 -46
- package/main/analytics.mjs +27 -0
- package/main/approval-prompt-server.mjs +2 -2
- package/main/artifact-path.mjs +11 -0
- package/main/claude-bin.mjs +31 -5
- package/main/claude-input.mjs +45 -3
- package/main/claude-plan.mjs +11 -11
- package/main/code-change.mjs +3 -3
- package/main/codex-account.mjs +1 -1
- package/main/codex-bin.mjs +17 -0
- package/main/codex-memory-gate.mjs +260 -0
- package/main/codex-models.mjs +12 -7
- package/main/codex-threads.mjs +46 -23
- package/main/codex-usage-file.mjs +7 -7
- package/main/codex-usage.mjs +4 -4
- package/main/codex-watch.mjs +12 -12
- package/main/config.mjs +68 -8
- package/main/dev-window.mjs +9 -9
- package/main/doc-scheme.mjs +2 -0
- package/main/engine-setup.mjs +193 -0
- package/main/feedback.mjs +55 -0
- package/main/folder-pictures.mjs +48 -0
- package/main/frame-scrollbars.mjs +90 -0
- package/main/fresh-user.mjs +6 -3
- package/main/git-change-offthread.mjs +70 -0
- package/main/git-change-thread.mjs +11 -0
- package/main/git-change.mjs +1 -1
- package/main/img-scheme.mjs +70 -1
- package/main/instruction-settings.mjs +92 -54
- package/main/ipc.mjs +316 -42
- package/main/leftovers.mjs +518 -0
- package/main/live-replies.mjs +43 -5
- package/main/machine.mjs +47 -0
- package/main/main.mjs +119 -15
- package/main/memory-gate-history.mjs +254 -0
- package/main/memory-gate-server.mjs +461 -0
- package/main/memory-gate.mjs +206 -0
- package/main/message-priority.mjs +87 -0
- package/main/rail-note.mjs +2 -2
- package/main/refresh-agent-models.mjs +20 -0
- package/main/repeats.mjs +4 -4
- package/main/row-label.mjs +114 -51
- package/main/serve.mjs +67 -5
- package/main/settings.mjs +223 -26
- package/main/ship-queue.mjs +279 -0
- package/main/sign-in-files.mjs +39 -0
- package/main/source-updater.mjs +298 -0
- package/main/store/home.mjs +47 -2
- package/main/store/work-items.mjs +150 -6
- package/main/store.mjs +235 -9
- package/main/supervisor.mjs +1444 -406
- package/main/task-commands.mjs +8 -10
- package/main/task-folders.mjs +74 -13
- package/main/task-remote-control.mjs +3 -3
- package/main/task-terminals.mjs +2 -1
- package/main/team/index.mjs +440 -0
- package/main/team/memory-cloud.mjs +247 -0
- package/main/team/projects.mjs +162 -0
- package/main/team/session.mjs +225 -0
- package/main/team/sign-in.mjs +106 -0
- package/main/team/supabase-backend.mjs +231 -0
- package/main/team/sync.mjs +163 -0
- package/main/updater.mjs +15 -5
- package/main/write-before-reload.mjs +50 -0
- package/mcp/core/page.mjs +9 -11
- package/mcp/core/work.mjs +91 -25
- package/mcp/tools.mjs +12 -6
- package/package.json +21 -9
- package/renderer/dist/assets/agentbox-icon-F1oN9nj_.svg +15 -0
- package/renderer/dist/assets/index-B-2Az952.css +32 -0
- package/renderer/dist/assets/index-D6qncgai.js +1168 -0
- package/renderer/dist/index.html +2 -2
- package/scripts/memory-gate-hook.sh +59 -0
- package/shared/agent-import.mjs +38 -15
- package/shared/agents.d.mts +26 -8
- package/shared/agents.mjs +100 -108
- package/shared/analytics-events.mjs +19 -2
- package/shared/answers.mjs +12 -0
- package/shared/bridge-map.d.mts +12 -0
- package/shared/bridge-map.mjs +37 -1
- package/shared/claude-commands.generated.mjs +2 -2
- package/shared/claude-models.generated.mjs +4 -4
- package/shared/claude-usage.mjs +6 -6
- package/shared/codex-import.mjs +7 -7
- package/shared/codex-modes.d.mts +50 -0
- package/shared/codex-usage.mjs +7 -8
- package/shared/demo-world.mjs +5 -5
- package/shared/engines.mjs +27 -10
- package/shared/feedback.d.mts +14 -0
- package/shared/feedback.mjs +44 -0
- package/shared/first-run-examples.mjs +4 -4
- package/shared/first-run-practice.d.mts +16 -0
- package/shared/first-run-practice.mjs +176 -70
- package/shared/fresh-user-home.mjs +22 -3
- package/shared/instruction-defaults.json +2 -2
- package/shared/product-name.mjs +4 -2
- package/shared/project-folder-check.mjs +6 -0
- package/shared/rank.d.mts +1 -0
- package/shared/rank.mjs +22 -0
- package/shared/referenced-files.mjs +1 -1
- package/shared/schemes.d.mts +11 -0
- package/shared/schemes.mjs +1 -1
- package/shared/shortcuts.d.mts +41 -0
- package/shared/shortcuts.mjs +236 -0
- package/shared/spawn-trouble.d.mts +2 -0
- package/shared/spawn-trouble.mjs +39 -18
- package/shared/team-rules.d.mts +17 -0
- package/shared/team-rules.mjs +230 -0
- package/shared/team-status.d.mts +11 -0
- package/shared/team-status.mjs +90 -0
- package/shared/terminal-links.d.mts +2 -0
- package/shared/terminal-links.mjs +55 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +16 -0
- package/shared/thread-cards.d.mts +19 -0
- package/shared/thread-cards.mjs +254 -0
- package/shared/usage.d.mts +1 -1
- package/shared/usage.mjs +6 -7
- package/shared/work-items.mjs +255 -27
- package/shared/work-lines.mjs +393 -16
- package/worker-permissions.json +1 -1
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-B7hhfHrT.js +0 -684
- package/renderer/dist/assets/index-CwiDv5bL.css +0 -32
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/emulator/audio-rules.mjs +0 -74
- package/renderer/dist/emulator/data/compression/README.md +0 -11
- package/renderer/dist/emulator/data/compression/extract7z.js +0 -1
- package/renderer/dist/emulator/data/compression/extractzip.js +0 -1
- package/renderer/dist/emulator/data/compression/libunrar.js +0 -519
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +0 -22
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +0 -65
- package/renderer/dist/emulator/data/cores/reports/mgba.json +0 -1
- package/renderer/dist/emulator/data/emulator.css +0 -1614
- package/renderer/dist/emulator/data/loader.js +0 -170
- package/renderer/dist/emulator/data/localization/README.md +0 -63
- package/renderer/dist/emulator/data/localization/af-FR.json +0 -339
- package/renderer/dist/emulator/data/localization/ar-AR.json +0 -301
- package/renderer/dist/emulator/data/localization/ben-BEN.json +0 -301
- package/renderer/dist/emulator/data/localization/de-GER.json +0 -301
- package/renderer/dist/emulator/data/localization/el-GR.json +0 -301
- package/renderer/dist/emulator/data/localization/en-US.json +0 -339
- package/renderer/dist/emulator/data/localization/es-ES.json +0 -310
- package/renderer/dist/emulator/data/localization/fa-AF.json +0 -310
- package/renderer/dist/emulator/data/localization/hi-HI.json +0 -301
- package/renderer/dist/emulator/data/localization/it-IT.json +0 -302
- package/renderer/dist/emulator/data/localization/ja-JA.json +0 -301
- package/renderer/dist/emulator/data/localization/jv-JV.json +0 -301
- package/renderer/dist/emulator/data/localization/ko-KO.json +0 -336
- package/renderer/dist/emulator/data/localization/pt-BR.json +0 -361
- package/renderer/dist/emulator/data/localization/retroarch.json +0 -617
- package/renderer/dist/emulator/data/localization/ro-RO.json +0 -310
- package/renderer/dist/emulator/data/localization/ru-RU.json +0 -301
- package/renderer/dist/emulator/data/localization/tr-TR.json +0 -310
- package/renderer/dist/emulator/data/localization/vi-VN.json +0 -302
- package/renderer/dist/emulator/data/localization/zh-CN.json +0 -351
- package/renderer/dist/emulator/data/src/GameManager.js +0 -464
- package/renderer/dist/emulator/data/src/compression.js +0 -142
- package/renderer/dist/emulator/data/src/emulator.js +0 -6158
- package/renderer/dist/emulator/data/src/gamepad.js +0 -167
- package/renderer/dist/emulator/data/src/nipplejs.js +0 -1
- package/renderer/dist/emulator/data/src/shaders.js +0 -236
- package/renderer/dist/emulator/data/src/socket.io.min.js +0 -7
- package/renderer/dist/emulator/data/src/storage.js +0 -125
- package/renderer/dist/emulator/data/version.json +0 -5
- package/renderer/dist/emulator/host.js +0 -299
- package/renderer/dist/emulator/index.html +0 -28
- package/renderer/dist/emulator/provenance.json +0 -6
package/main/task-commands.mjs
CHANGED
|
@@ -26,10 +26,10 @@ const exec = promisify(execFile);
|
|
|
26
26
|
* a chat of its own, that takes over and nothing forks twice.
|
|
27
27
|
*/
|
|
28
28
|
/**
|
|
29
|
-
* A row name out of the sentence
|
|
29
|
+
* A row name out of the sentence typed after `/fork`: its first sentence,
|
|
30
30
|
* or the first sixty characters of it, so the inbox line says which fork this
|
|
31
31
|
* is rather than which row it came from. Whitespace flattened because a title
|
|
32
|
-
* is one line and
|
|
32
|
+
* is one line and a paragraph can be pasted.
|
|
33
33
|
*/
|
|
34
34
|
export function forkTitle(words, cap = 60) {
|
|
35
35
|
const flat = String(words ?? '').replace(/\s+/g, ' ').trim();
|
|
@@ -45,18 +45,16 @@ function forkTask(sup, productSlug, id, item, engine, op, publish) {
|
|
|
45
45
|
}
|
|
46
46
|
// NAMING THE KEY, because the menu closes at the space and plain return then
|
|
47
47
|
// writes a newline, the way it does on every other reply here. Without that
|
|
48
|
-
// sentence
|
|
49
|
-
// does nothing
|
|
50
|
-
// hit return").
|
|
48
|
+
// sentence the user types the words, presses return, and is back to a key
|
|
49
|
+
// that does nothing, so a bare /fork looks as if it did nothing.
|
|
51
50
|
if (!op.args) return publish('failed', 'Say what the fork should try, as in /fork try it without the modal, then press command and return.', op.name);
|
|
52
51
|
const chat = sup.rowSessionFor(item, sup.store.listProducts().find((p) => p.slug === productSlug));
|
|
53
52
|
if (!chat?.sessionId) return publish('failed', 'This task has no saved conversation to fork yet. Send a message first.', op.name);
|
|
54
53
|
const made = sup.store.composeItem(productSlug, {
|
|
55
|
-
// THE FORK IS NAMED AFTER WHAT
|
|
56
|
-
// came from. Naming it `<parent title> · fork` put two rows in
|
|
57
|
-
// whose names differ by one word at the end,
|
|
58
|
-
//
|
|
59
|
-
// (2026-09-23). The instruction is the only thing that distinguishes them,
|
|
54
|
+
// THE FORK IS NAMED AFTER WHAT IT WAS ASKED TO TRY, not after the row it
|
|
55
|
+
// came from. Naming it `<parent title> · fork` put two rows in the inbox
|
|
56
|
+
// whose names differ by one word at the end, so there was no telling which
|
|
57
|
+
// agent was which. The instruction is the only thing that distinguishes them,
|
|
60
58
|
// so it is what the row is called; the parent is still on the row as the
|
|
61
59
|
// `fork:` label, which is where the machinery reads it anyway.
|
|
62
60
|
title: forkTitle(op.args),
|
package/main/task-folders.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// ONE FOLDER PER TASK, OWNED BY THE APP RATHER THAN BY THE AGENT IN IT.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// The two engines are not equal here. Claude Code has isolation built in
|
|
3
|
+
// Between letting Claude Code make its own worktree and doing it ourselves, the
|
|
4
|
+
// app makes the folder for both agents. The two engines are not equal here. Claude Code has isolation built in
|
|
6
5
|
// (`--worktree`), makes the folder under `.claude/worktrees/`, locks it, reuses
|
|
7
6
|
// it by name and deletes it when it leaves nothing behind. Codex has none of
|
|
8
7
|
// that, only `--cd` for choosing where to work. So the rules have to live on
|
|
@@ -11,7 +10,7 @@
|
|
|
11
10
|
// exactly where Claude Code would, so a person who opens the repository in a
|
|
12
11
|
// terminal sees the layout they already know.
|
|
13
12
|
//
|
|
14
|
-
// WHAT WAS MEASURED,
|
|
13
|
+
// WHAT WAS MEASURED, 2026-09-22. This repository had 25 leftover
|
|
15
14
|
// agent copies holding 26 GB: twelve with edits committed nowhere, twelve on
|
|
16
15
|
// branches never merged, one safe to delete. Isolation was a rule in the worker
|
|
17
16
|
// brief; the rule's first half (make a copy) was kept and its second half (fold
|
|
@@ -51,6 +50,9 @@ const BRANCH_PREFIX = `${nameSlug}/`;
|
|
|
51
50
|
/** Ids are `w-` plus hex today. This is the guard for the day they are not. */
|
|
52
51
|
const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
53
52
|
|
|
53
|
+
/** What an unfinished folder is called. A task name may never start with a dot. */
|
|
54
|
+
const BUILDING = '.building-';
|
|
55
|
+
|
|
54
56
|
const git = (cwd, args) => execFileSync('git', args, {
|
|
55
57
|
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
58
|
}).trim();
|
|
@@ -81,6 +83,32 @@ export function taskFolderPath(repoPath, id) {
|
|
|
81
83
|
return path.join(real(repoPath), ...WORKTREES, safeTaskName(id));
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
/**
|
|
87
|
+
* WHERE A FOLDER IS BUILT, WHICH IS NEVER WHERE IT IS RUN IN.
|
|
88
|
+
*
|
|
89
|
+
* Making one is a worktree plus a block clone of the checkout. It used to be
|
|
90
|
+
* built AT the path a session runs in, so for the whole of that a directory sat
|
|
91
|
+
* there, registered with git and holding almost none of the code. MEASURED on
|
|
92
|
+
* astral-video, the repository this happened in, 2026-10-01: the build takes 115
|
|
93
|
+
* seconds now that the checkout is 14 GB, and the path a session runs in
|
|
94
|
+
* appeared 2.7 seconds in. That is 112 seconds of a folder that looked ready and
|
|
95
|
+
* was not, against a supervisor tick of fifteen seconds, so seven ticks could
|
|
96
|
+
* read it. Everything that asks whether a row's folder is ready asks the disk,
|
|
97
|
+
* which means for those 112 seconds every one of them said yes:
|
|
98
|
+
* the supervisor started a session in a half written folder, and when the build
|
|
99
|
+
* finished it started a second session on the same row (2026-09-30,
|
|
100
|
+
* w-4d722ecf92, where the second one's claim came back refused and the first
|
|
101
|
+
* one's folder was deleted under it a minute in).
|
|
102
|
+
*
|
|
103
|
+
* Built here and MOVED into place once it is finished and checked, so a
|
|
104
|
+
* directory at the real path means a folder that is complete. That makes every
|
|
105
|
+
* existing question correct without any of them learning a new one, and it
|
|
106
|
+
* leaves the rollback below nothing to delete but its own unfinished copy.
|
|
107
|
+
*/
|
|
108
|
+
export function buildingFolderPath(repoPath, id) {
|
|
109
|
+
return path.join(real(repoPath), ...WORKTREES, `${BUILDING}${safeTaskName(id)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
export function taskBranch(id) { return `${BRANCH_PREFIX}${safeTaskName(id)}`; }
|
|
85
113
|
|
|
86
114
|
/** The repository root, or null when this is not a checkout at all. */
|
|
@@ -156,20 +184,48 @@ export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = tr
|
|
|
156
184
|
|
|
157
185
|
hideFromTheCheckout(root);
|
|
158
186
|
|
|
187
|
+
// EVERY STEP BELOW HAPPENS SOMEWHERE ELSE, and the folder appears at its own
|
|
188
|
+
// path in one move at the end. `buildingFolderPath` is why. A build the app
|
|
189
|
+
// died in the middle of leaves an unfinished copy there, which nobody wants
|
|
190
|
+
// and which would refuse the next attempt, so it goes first.
|
|
191
|
+
const staging = buildingFolderPath(root, name);
|
|
192
|
+
clearUnfinished(root, staging);
|
|
193
|
+
|
|
159
194
|
const branchExists = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
|
|
160
195
|
const from = branchExists ? branch : baseRef(root);
|
|
161
|
-
if (!cloneCheckout(root,
|
|
196
|
+
if (!cloneCheckout(root, staging, branch, from, branchExists)) {
|
|
162
197
|
const add = branchExists
|
|
163
|
-
? tryGit(root, ['worktree', 'add',
|
|
164
|
-
: tryGit(root, ['worktree', 'add', '-b', branch,
|
|
198
|
+
? tryGit(root, ['worktree', 'add', staging, branch])
|
|
199
|
+
: tryGit(root, ['worktree', 'add', '-b', branch, staging, baseRef(root)]);
|
|
165
200
|
if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
|
|
166
|
-
if (dependencies) cloneDependencies(root,
|
|
201
|
+
if (dependencies) cloneDependencies(root, staging);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// `worktree move` rather than a rename, so git's own record of where this
|
|
205
|
+
// worktree lives moves with it. It refuses if anything is already at the
|
|
206
|
+
// destination, which is the last guard against walking over a live folder.
|
|
207
|
+
const moved = tryGit(root, ['worktree', 'move', staging, folder]);
|
|
208
|
+
if (!moved.ok) {
|
|
209
|
+
clearUnfinished(root, staging);
|
|
210
|
+
throw Error(`Could not make a folder for ${name}: ${moved.out}`);
|
|
167
211
|
}
|
|
168
212
|
|
|
169
213
|
lock(root, folder, pid);
|
|
170
214
|
return { path: folder, branch, created: true };
|
|
171
215
|
}
|
|
172
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Throw away an unfinished copy. Nothing is ever lost here: a staging folder
|
|
219
|
+
* has never been handed to anybody, so the most it can hold is a checkout that
|
|
220
|
+
* did not finish. The branch is left alone, as everywhere else in this file.
|
|
221
|
+
*/
|
|
222
|
+
function clearUnfinished(root, staging) {
|
|
223
|
+
if (!fs.existsSync(staging) && !record(root, staging)) return;
|
|
224
|
+
tryGit(root, ['worktree', 'remove', '--force', staging]);
|
|
225
|
+
try { fs.rmSync(staging, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
226
|
+
tryGit(root, ['worktree', 'prune']);
|
|
227
|
+
}
|
|
228
|
+
|
|
173
229
|
/**
|
|
174
230
|
* MAKING THE FOLDER WITHOUT PAYING FOR IT TWICE.
|
|
175
231
|
*
|
|
@@ -217,10 +273,12 @@ function cloneCheckout(root, folder, branch, from, branchExists) {
|
|
|
217
273
|
if (!after.ok || after.out.length > 0) throw Error('the clone did not match the commit');
|
|
218
274
|
return true;
|
|
219
275
|
} catch {
|
|
220
|
-
// Back to nothing, so the ordinary path starts from a clean sheet.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
276
|
+
// Back to nothing, so the ordinary path starts from a clean sheet. What is
|
|
277
|
+
// thrown away here is the UNFINISHED COPY and never a folder anybody is in:
|
|
278
|
+
// `folder` is the staging path, which no session has ever been given. It
|
|
279
|
+
// used to be the real one, and a `remove --force` of that is how a running
|
|
280
|
+
// agent lost the ground under it.
|
|
281
|
+
clearUnfinished(root, folder);
|
|
224
282
|
return false;
|
|
225
283
|
}
|
|
226
284
|
}
|
|
@@ -390,7 +448,10 @@ export function listTaskFolders(dir) {
|
|
|
390
448
|
if (!root) return [];
|
|
391
449
|
const home = path.join(root, ...WORKTREES);
|
|
392
450
|
return registered(root)
|
|
393
|
-
|
|
451
|
+
// A folder still being built is not one of hers: it has no row standing in
|
|
452
|
+
// it and nothing in it is wanted, so it may never be listed, shown or swept.
|
|
453
|
+
.filter((w) => path.dirname(w.path) === home && !w.prunable
|
|
454
|
+
&& !path.basename(w.path).startsWith(BUILDING))
|
|
394
455
|
.map((w) => {
|
|
395
456
|
const { dirty, merged } = folderState(root, w.path);
|
|
396
457
|
let touchedAt = null;
|
|
@@ -18,9 +18,9 @@ export async function taskRemoteControl(sup,product,id,action='status') {
|
|
|
18
18
|
if(sup._engineFor(item)==='codex')throw Error('Remote Control is currently available for Claude Code conversations only.');
|
|
19
19
|
if(action==='dismiss')return current()?.state==='failed'&&!current()?.mayBeActive?publish('off'):current();
|
|
20
20
|
if(current()?.state==='connecting'||current()?.state==='disconnecting')return current();
|
|
21
|
-
// Typing the command IS the decision, so it never stops at a card asking again
|
|
22
|
-
//
|
|
23
|
-
//
|
|
21
|
+
// Typing the command IS the decision, so it never stops at a card asking again
|
|
22
|
+
// (w-79bffd7ec3): it turns remote control on or off depending on its current
|
|
23
|
+
// state, with no further confirmation.
|
|
24
24
|
if(action==='toggle')action=['connected','ready','reconnecting'].includes(current()?.state)||current()?.mayBeActive?'disable':'enable';
|
|
25
25
|
if(action==='enable'&&['connected','ready','reconnecting'].includes(current()?.state))return current();
|
|
26
26
|
let session=sup.sessions.get(id);
|
package/main/task-terminals.mjs
CHANGED
|
@@ -133,7 +133,8 @@ export class TaskTerminals {
|
|
|
133
133
|
}
|
|
134
134
|
const place=this.resolve(key);
|
|
135
135
|
const cwd=typeof place==='string'?place:place.cwd;
|
|
136
|
-
const
|
|
136
|
+
const command=place?.command;
|
|
137
|
+
const pty=this.spawn(command?.file??this.shell,command?.args??['-l'],{cwd,env:{...this.env,...command?.env},cols:80,rows:24,name:'xterm-256color'});
|
|
137
138
|
let resolveExit;const exitPromise=new Promise(resolve=>{resolveExit=resolve;});
|
|
138
139
|
const session={token:randomUUID(),pty,cwd,buffer:'',offset:0,exitCode:null,exited:false,exitPromise,lastReadAt:this.now()};this.sessions.set(key,session);
|
|
139
140
|
/*
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
// THE TEAM SERVICE: who is signed in on this Mac, their team, and the sync
|
|
2
|
+
// that keeps shared projects the same on every teammate's Mac.
|
|
3
|
+
//
|
|
4
|
+
// It owns one session (Supabase in the app, memory in tests) and, once someone
|
|
5
|
+
// is signed in:
|
|
6
|
+
// - stamps every ledger line this Mac writes with their person id, here and
|
|
7
|
+
// in the store server the supervisor starts (AGENTBOX_PERSON_ID);
|
|
8
|
+
// - shows them the invites waiting for their email, and joins one only when
|
|
9
|
+
// they say yes (acceptInvite). Once this Mac is in a team it stays in that
|
|
10
|
+
// team: an invite, or an older team somebody made, never moves it;
|
|
11
|
+
// - syncs every few seconds, and at once when a teammate writes;
|
|
12
|
+
// - answers the window's questions: who am I, who is on my team, what are
|
|
13
|
+
// my teammates' private tasks doing.
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { createTeamSync, fileSyncState } from './sync.mjs';
|
|
16
|
+
import { listSharedProjects, joinSharedProject, markShared, makeDirect } from './projects.mjs';
|
|
17
|
+
import { firstSentence } from '../../shared/thread-cards.mjs';
|
|
18
|
+
import { cardsFor } from '../../shared/thread-cards.mjs';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import { hasLapsed, statusEnds } from '../../shared/team-status.mjs';
|
|
21
|
+
|
|
22
|
+
// `signingIn` is { url } while a Google sign-in waits on the browser, so the
|
|
23
|
+
// page can offer that link again rather than a button that does nothing.
|
|
24
|
+
const EMPTY = { configured: false, started: false, signedIn: false, signingIn: null, me: null, team: null, invites: [], sent: [], people: [], cards: [], lastSyncAt: null, error: null };
|
|
25
|
+
|
|
26
|
+
export function createTeamService({
|
|
27
|
+
session, store, disk, accountRoot, stateFile, onChange = () => {}, log = () => {}, intervalMs = 5000, startRetryMs = 1500,
|
|
28
|
+
// The clock, so a test can move past the moment a status runs out.
|
|
29
|
+
now = Date.now,
|
|
30
|
+
}) {
|
|
31
|
+
let state = { ...EMPTY, configured: !!session?.configured };
|
|
32
|
+
let backend = null;
|
|
33
|
+
let sync = null;
|
|
34
|
+
let timer = null;
|
|
35
|
+
let unsubscribe = null;
|
|
36
|
+
let pending = null;
|
|
37
|
+
// The sync cursors and which team this Mac is in, in one file.
|
|
38
|
+
let local = null;
|
|
39
|
+
const kept = () => (local ??= fileSyncState(stateFile, fs));
|
|
40
|
+
|
|
41
|
+
const set = (patch) => { state = { ...state, ...patch }; onChange(); };
|
|
42
|
+
|
|
43
|
+
function products() {
|
|
44
|
+
try { return store.listProducts(); } catch { return []; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A status the owner set has run out: wipe it where it is stored, so old
|
|
48
|
+
// text does not sit in the cloud. Everyone who READS it already saw it go,
|
|
49
|
+
// on their own clock (shared/team-status.mjs); this is the tidying.
|
|
50
|
+
async function clearLapsedStatus() {
|
|
51
|
+
if (!backend || !hasLapsed(state.me?.status, now())) return;
|
|
52
|
+
await backend.setStatus({ text: null, until: null });
|
|
53
|
+
set({ me: { ...state.me, status: null } });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// THE MESSAGE RECORD OF EXACTLY TWO PEOPLE, made by one of them (see
|
|
57
|
+
// `message` below for why both halves matter).
|
|
58
|
+
// `to` is one person or several (2026-10-01: messages to a few people at
|
|
59
|
+
// once). The record must hold EXACTLY you and them, and one of you made it.
|
|
60
|
+
function directWith(me, to) {
|
|
61
|
+
const two = new Set([me, ...[].concat(to)]);
|
|
62
|
+
const justUs = (t) => {
|
|
63
|
+
const on = new Set([...t.people, ...(t.sharedBy ? [t.sharedBy] : [])]);
|
|
64
|
+
return two.has(t.sharedBy) && on.size === two.size && [...on].every((p) => two.has(p));
|
|
65
|
+
};
|
|
66
|
+
// ONE RECORD FOR THE TWO OF YOU, THE SAME ONE ON BOTH MACS. Two people who
|
|
67
|
+
// write each other first within the same second each make one (measured
|
|
68
|
+
// 2026-10-01 on two test copies), and each Mac then kept writing into its
|
|
69
|
+
// own. Both now settle on the record with the lowest id, so the talk
|
|
70
|
+
// comes back together the moment both records are known.
|
|
71
|
+
return products().filter((p) => p.team?.direct && justUs(p.team))
|
|
72
|
+
.sort((a, b) => String(a.team.projectId).localeCompare(String(b.team.projectId)))[0] ?? null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// THE CONVERSATION IN A MESSAGE RECORD: its most recently touched row. Older
|
|
76
|
+
// records may hold one row per message, from before a message continued the
|
|
77
|
+
// conversation, and the newest of those is where the talk is.
|
|
78
|
+
function conversationIn(slug) {
|
|
79
|
+
let rows = [];
|
|
80
|
+
try { rows = store.listItems().filter((i) => i.product === slug && !i.agent); } catch { rows = []; }
|
|
81
|
+
return rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0] ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// THE TEAM THIS MAC IS IN IS THE ONE IT REMEMBERS. Found by review
|
|
85
|
+
// 2026-10-01: the app took whichever of your teams was oldest, and joined
|
|
86
|
+
// every team that invited you at each start, so a team made with a
|
|
87
|
+
// backdated created_at and an invite moved your work into it unasked. Now
|
|
88
|
+
// the first team is remembered, and a remembered team you are no longer on
|
|
89
|
+
// leaves you in no team rather than in another one.
|
|
90
|
+
async function refreshTeam() {
|
|
91
|
+
const me = state.me?.id;
|
|
92
|
+
const teams = await backend.myTeams();
|
|
93
|
+
const remembered = me ? kept().getTeam(me) : null;
|
|
94
|
+
// '' is "left a team, and joins nothing until asked": never quietly the next one.
|
|
95
|
+
const team = remembered === '' ? null : remembered ? teams.find((t) => t.id === remembered) ?? null : teams[0] ?? null;
|
|
96
|
+
if (team && !remembered && me) kept().setTeam(me, team.id);
|
|
97
|
+
// The moment sharing starts on this Mac, kept so it never moves later.
|
|
98
|
+
if (team && me && !kept().getSince(me)) kept().setSince(me, Date.now());
|
|
99
|
+
const people = team ? await backend.teamPeople(team.id) : [];
|
|
100
|
+
const cards = team ? await backend.listCards() : [];
|
|
101
|
+
// An invite lookup that fails (an older cloud, a dropped request) leaves
|
|
102
|
+
// you signed in with nothing offered, never signed out.
|
|
103
|
+
const invites = team ? [] : await backend.pendingInvites().catch((err) => { log(`team: invites: ${err.message}`); return []; });
|
|
104
|
+
// The invites this team has out, so the settings page can say who has not
|
|
105
|
+
// joined yet and let them be cancelled.
|
|
106
|
+
const sent = team && backend.listInvites ? await backend.listInvites(team.id).catch(() => []) : [];
|
|
107
|
+
set({ team, people, cards, invites, sent, since: me ? kept().getSince(me) : null });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function syncNow() {
|
|
111
|
+
if (!sync) return null;
|
|
112
|
+
if (pending) return pending;
|
|
113
|
+
// THE BODY WAITS A MICROTASK, SO `pending` IS SET BEFORE ANY OF IT RUNS.
|
|
114
|
+
// Written as `pending = (async () => {...})()` the body's synchronous
|
|
115
|
+
// prefix runs BEFORE the assignment, so `pending` was still null while it
|
|
116
|
+
// ran. That was harmless until a pass wrote to the cloud: clearing a
|
|
117
|
+
// lapsed status makes the memory cloud emit, the emit calls back into
|
|
118
|
+
// syncNow, the guard is still null, and it recurses until the stack ends.
|
|
119
|
+
// Measured on the first run of the lapse test: a stack overflow, every
|
|
120
|
+
// frame this function.
|
|
121
|
+
pending = Promise.resolve().then(async () => {
|
|
122
|
+
try {
|
|
123
|
+
// An invite sent after this person signed in shows up here, not at
|
|
124
|
+
// their next sign-in. It is offered, never taken up.
|
|
125
|
+
if (!state.team) await refreshTeam();
|
|
126
|
+
await clearLapsedStatus();
|
|
127
|
+
const report = await sync.syncOnce();
|
|
128
|
+
// The people are read again on every pass, with the activity, so a
|
|
129
|
+
// teammate who joined after this Mac signed in has a name and a face
|
|
130
|
+
// on the first line of theirs that arrives here.
|
|
131
|
+
const [cards, people, me] = state.team
|
|
132
|
+
? await Promise.all([backend.listCards(), backend.teamPeople(state.team.id), backend.me()])
|
|
133
|
+
: [[], [], state.me];
|
|
134
|
+
set({ lastSyncAt: Date.now(), error: null, cards, people, me: me ?? state.me });
|
|
135
|
+
if (report.joined.length || report.pulled) log(`team: joined ${report.joined.length}, pulled ${report.pulled}, pushed ${report.pushed}`);
|
|
136
|
+
if (report.notJoined) log(`team: left ${report.notJoined} shared project(s) unjoined, this Mac already holds the most it takes on by itself`);
|
|
137
|
+
return report;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
set({ error: String(err?.message ?? err) });
|
|
140
|
+
return null;
|
|
141
|
+
} finally {
|
|
142
|
+
pending = null;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
return pending;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stopLoop() {
|
|
149
|
+
if (timer) clearInterval(timer);
|
|
150
|
+
timer = null;
|
|
151
|
+
unsubscribe?.();
|
|
152
|
+
unsubscribe = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function signedIn(b) {
|
|
156
|
+
backend = b;
|
|
157
|
+
const me = await backend.me();
|
|
158
|
+
if (!me) throw new Error('signed in, but no person record came back');
|
|
159
|
+
disk.setLineAuthor(me.id);
|
|
160
|
+
process.env.AGENTBOX_PERSON_ID = me.id;
|
|
161
|
+
set({ signedIn: true, me, error: null });
|
|
162
|
+
// MY WORKSPACE IS NOT MADE HERE ANY MORE (w-f8d123be62, approved
|
|
163
|
+
// 2026-10-05). It was, and that was the whole bug: the only caller was this
|
|
164
|
+
// one, inside signing in, and this service is not even built on a Mac with
|
|
165
|
+
// no team cloud. So the single-person app opened on no projects at all.
|
|
166
|
+
// Everybody gets one at boot now (main/main.mjs), signed in or not.
|
|
167
|
+
await refreshTeam();
|
|
168
|
+
sync = createTeamSync({
|
|
169
|
+
backend,
|
|
170
|
+
disk,
|
|
171
|
+
state: kept(),
|
|
172
|
+
// ARCHIVED ONES COUNT AS HERE. Asked of the visible list, a shared
|
|
173
|
+
// project you archived read as missing and was joined again beside it.
|
|
174
|
+
listShared: () => { try { return listSharedProjects(store.listProducts({ includeArchived: true })); } catch { return []; } },
|
|
175
|
+
joinProject: (project) => joinSharedProject(accountRoot, project),
|
|
176
|
+
listCards: () => cardsFor({ products: products(), readItems: (p) => disk.readWorkItems(p.dir), since: state.me ? kept().getSince(state.me.id) : null }),
|
|
177
|
+
teamIdOf: () => state.team?.id ?? null,
|
|
178
|
+
});
|
|
179
|
+
stopLoop();
|
|
180
|
+
timer = setInterval(() => { syncNow(); }, intervalMs);
|
|
181
|
+
timer.unref?.();
|
|
182
|
+
unsubscribe = backend.subscribe(() => { syncNow(); });
|
|
183
|
+
await syncNow();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
state: () => state,
|
|
188
|
+
|
|
189
|
+
// THREE TRIES AT START, because one dropped request left the app signed
|
|
190
|
+
// out until it was restarted (measured 2026-10-01 on a loaded Mac: the
|
|
191
|
+
// sign-in went through and the very next call failed). A session that is
|
|
192
|
+
// simply not there is not an error and is not retried.
|
|
193
|
+
// `started` says the first look for a saved sign-in is over, so the window
|
|
194
|
+
// can tell "signed out" from "still finding out" and never flashes the
|
|
195
|
+
// sign-in page at somebody who is signed in.
|
|
196
|
+
async start() {
|
|
197
|
+
if (!session?.configured) return state;
|
|
198
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
199
|
+
try {
|
|
200
|
+
const b = await session.restore();
|
|
201
|
+
if (b) await signedIn(b);
|
|
202
|
+
set({ started: true });
|
|
203
|
+
return state;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
set({ error: String(err?.message ?? err) });
|
|
206
|
+
if (attempt < 2) await new Promise((r) => setTimeout(r, startRetryMs * (attempt + 1)));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
set({ started: true });
|
|
210
|
+
return state;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async signIn() {
|
|
214
|
+
let b;
|
|
215
|
+
try {
|
|
216
|
+
b = await session.signIn({ onUrl: (url) => set({ signingIn: { url } }), log });
|
|
217
|
+
} finally {
|
|
218
|
+
if (state.signingIn) set({ signingIn: null });
|
|
219
|
+
}
|
|
220
|
+
await signedIn(b);
|
|
221
|
+
return state;
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
/** Open the waiting Google sign-in's own link again. False if none waits. */
|
|
225
|
+
async reopenSignIn() {
|
|
226
|
+
const url = state.signingIn?.url;
|
|
227
|
+
if (!url || !session?.reopen) return false;
|
|
228
|
+
log('team: Google sign-in: opening the browser again');
|
|
229
|
+
await session.reopen(url);
|
|
230
|
+
return true;
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
cancelSignIn() {
|
|
234
|
+
return session?.cancelSignIn?.() ?? false;
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async signInWithEmail(email, password) {
|
|
238
|
+
if (!session?.signInWithPassword) throw new Error('this build cannot sign in with email');
|
|
239
|
+
const b = await session.signInWithPassword(email, password);
|
|
240
|
+
await signedIn(b);
|
|
241
|
+
return state;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
/** A new account: signed in at once, or { confirm: true } until the email is confirmed. */
|
|
245
|
+
async signUp(email, password, name) {
|
|
246
|
+
if (!session?.signUp) throw new Error('this build cannot make accounts');
|
|
247
|
+
const out = await session.signUp({ email, password, name });
|
|
248
|
+
if (out?.backend) { await signedIn(out.backend); return state; }
|
|
249
|
+
return { ...state, confirm: true };
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async signOut() {
|
|
253
|
+
stopLoop();
|
|
254
|
+
await session.signOut?.();
|
|
255
|
+
disk.setLineAuthor(null);
|
|
256
|
+
delete process.env.AGENTBOX_PERSON_ID;
|
|
257
|
+
backend = null;
|
|
258
|
+
sync = null;
|
|
259
|
+
// Signed out is a known answer, so the window shows the sign-in page at once.
|
|
260
|
+
state = { ...EMPTY, configured: !!session?.configured, started: true };
|
|
261
|
+
onChange();
|
|
262
|
+
return state;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async createTeam(name) {
|
|
266
|
+
if (!backend) throw new Error('sign in first');
|
|
267
|
+
const clean = String(name ?? '').trim();
|
|
268
|
+
if (!clean) throw new Error('a team needs a name');
|
|
269
|
+
const made = await backend.createTeam(clean);
|
|
270
|
+
kept().setTeam(state.me.id, made.id);
|
|
271
|
+
await refreshTeam();
|
|
272
|
+
return state;
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
// TEAM SETTINGS (2026-10-01): an invite page and team settings beside it.
|
|
276
|
+
// The database decides who may (owners rename,
|
|
277
|
+
// remove and cancel; anyone leaves); these only ask it and say what it said.
|
|
278
|
+
async renameTeam(name) {
|
|
279
|
+
if (!backend || !state.team) throw new Error('start or join a team first');
|
|
280
|
+
const clean = String(name ?? '').trim();
|
|
281
|
+
if (!clean) throw new Error('a team needs a name');
|
|
282
|
+
await backend.renameTeam(state.team.id, clean);
|
|
283
|
+
await refreshTeam();
|
|
284
|
+
return state;
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
// A line you write about yourself, held until the end of today, tomorrow
|
|
288
|
+
// or this week, or until you clear it. Saying nothing clears it.
|
|
289
|
+
async setStatus({ text, hold = 'open', until } = {}) {
|
|
290
|
+
if (!backend) throw new Error('sign in first');
|
|
291
|
+
const status = await backend.setStatus({ text, until: statusEnds({ hold, until }, now()) });
|
|
292
|
+
set({ me: { ...state.me, status } });
|
|
293
|
+
return state;
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
async removeMember(personId) {
|
|
297
|
+
if (!backend || !state.team) throw new Error('start or join a team first');
|
|
298
|
+
if (personId === state.me?.id) throw new Error('to leave the team, use Leave team');
|
|
299
|
+
await backend.removeMember(state.team.id, personId);
|
|
300
|
+
await refreshTeam();
|
|
301
|
+
return state;
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async leaveTeam() {
|
|
305
|
+
if (!backend || !state.team || !state.me) throw new Error('you are not on a team');
|
|
306
|
+
await backend.removeMember(state.team.id, state.me.id);
|
|
307
|
+
kept().setTeam(state.me.id, '');
|
|
308
|
+
await refreshTeam();
|
|
309
|
+
return state;
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
async cancelInvite(email) {
|
|
313
|
+
if (!backend || !state.team) throw new Error('start or join a team first');
|
|
314
|
+
await backend.cancelInvite(state.team.id, email);
|
|
315
|
+
await refreshTeam();
|
|
316
|
+
return state;
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
// JOIN A TEAM BECAUSE ITS INVITE WAS ANSWERED YES, and only then. A Mac
|
|
320
|
+
// already in a team does not move: that is a choice nobody offers here.
|
|
321
|
+
async acceptInvite(teamId) {
|
|
322
|
+
if (!backend || !state.me) throw new Error('sign in first');
|
|
323
|
+
if (state.team) throw new Error(`this Mac is already in ${state.team.name}`);
|
|
324
|
+
if (!state.invites.some((i) => i.teamId === teamId)) throw new Error('that invite is no longer waiting');
|
|
325
|
+
await backend.acceptInvite(teamId);
|
|
326
|
+
kept().setTeam(state.me.id, teamId);
|
|
327
|
+
await refreshTeam();
|
|
328
|
+
await syncNow();
|
|
329
|
+
return state;
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
async invite(email) {
|
|
333
|
+
if (!backend || !state.team) throw new Error('start or join a team first');
|
|
334
|
+
const clean = String(email ?? '').trim();
|
|
335
|
+
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(clean)) throw new Error(`${clean || 'that'} is not an email address`);
|
|
336
|
+
await backend.invite(state.team.id, clean);
|
|
337
|
+
// So the settings page lists it as invited straight away.
|
|
338
|
+
await refreshTeam().catch(() => {});
|
|
339
|
+
return state;
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
// Share a local project with the whole team or with specific people. The
|
|
343
|
+
// project keeps one cloud id for good (markShared), so changing who sees
|
|
344
|
+
// it is the same project.
|
|
345
|
+
async share(slug, { visibility = 'team', people = [] } = {}) {
|
|
346
|
+
if (!backend || !state.team) throw new Error('start or join a team first');
|
|
347
|
+
const product = products().find((p) => p.slug === slug);
|
|
348
|
+
if (!product) throw new Error(`no project ${slug}`);
|
|
349
|
+
const team = markShared(product.dir, { teamId: state.team.id, visibility, people, sharedBy: state.me.id });
|
|
350
|
+
await backend.shareProject({ id: team.projectId, teamId: state.team.id, name: product.name, visibility: team.visibility, people: team.people });
|
|
351
|
+
onChange();
|
|
352
|
+
await syncNow();
|
|
353
|
+
return state;
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
// A MESSAGE TO A PERSON (approved 2026-10-01: people get messages, never
|
|
357
|
+
// tasks). It goes into the record the two of them share, made the first
|
|
358
|
+
// time and reused after, and it waits in their inbox until they answer.
|
|
359
|
+
/** The conversation in a message record: its newest row, if it has one. */
|
|
360
|
+
conversation(to) {
|
|
361
|
+
const me = state.me?.id;
|
|
362
|
+
if (!me || !to) return null;
|
|
363
|
+
const product = directWith(me, to);
|
|
364
|
+
const convo = product ? conversationIn(product.slug) : null;
|
|
365
|
+
return convo ? { product: product.slug, id: convo.id } : null;
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* `priority` is the level the sender picked on the card, and it counts on
|
|
370
|
+
* the FIRST message only (w-7ba439c883): the conversation's place in the
|
|
371
|
+
* other person's inbox was settled when it opened, and a reply three days
|
|
372
|
+
* later is not a chance to lift it. Nothing picked means nothing sent, so
|
|
373
|
+
* the row keeps composeItem's 0, which the inbox reads as Medium.
|
|
374
|
+
*/
|
|
375
|
+
async message(toIn, body, priority) {
|
|
376
|
+
if (!backend || !state.team || !state.me) throw new Error('start or join a team first');
|
|
377
|
+
const me = state.me.id;
|
|
378
|
+
// One person or a few: the same conversation for the same people.
|
|
379
|
+
const others = [...new Set([].concat(toIn ?? []).filter((p) => p && p !== me))].sort();
|
|
380
|
+
if (!others.length) throw new Error('pick someone to message');
|
|
381
|
+
if (others.length > 11) throw new Error('a message goes to at most eleven people');
|
|
382
|
+
if (others.some((p) => !state.people.some((q) => q.id === p))) throw new Error('that person is not on your team');
|
|
383
|
+
const to = others.length === 1 ? others[0] : others;
|
|
384
|
+
const text = String(body ?? '').trim();
|
|
385
|
+
if (!text) throw new Error('a message needs some words');
|
|
386
|
+
// THE RECORD IS REUSED ONLY IF IT IS EXACTLY THE TWO OF YOU AND ONE OF
|
|
387
|
+
// YOU MADE IT. Found by review 2026-10-01: a teammate could make a
|
|
388
|
+
// "direct" record holding you and somebody else, and the next message
|
|
389
|
+
// either of you sent the other went into it, where its maker read it.
|
|
390
|
+
let product = directWith(me, to);
|
|
391
|
+
// Before making a record, look once more: the other person may have just
|
|
392
|
+
// made one, and the pull is what brings it here.
|
|
393
|
+
if (!product) { await syncNow(); product = directWith(me, to); }
|
|
394
|
+
if (!product) {
|
|
395
|
+
const made = makeDirect(accountRoot, { teamId: state.team.id, me, others });
|
|
396
|
+
// A START THAT FAILS LEAVES NOTHING BEHIND. A record shared only in
|
|
397
|
+
// part (the cloud refused its people) was reused by the next send and
|
|
398
|
+
// never reached anyone (found 2026-10-01 on a test copy).
|
|
399
|
+
try {
|
|
400
|
+
await backend.shareProject({ id: made.projectId, teamId: state.team.id, name: 'Direct', visibility: 'people', people: others, direct: true });
|
|
401
|
+
} catch (err) {
|
|
402
|
+
try { fs.rmSync(made.dir, { recursive: true, force: true }); } catch { /* the error below still says what failed */ }
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
product = products().find((p) => p.dir === made.dir) ?? { slug: made.slug, dir: made.dir };
|
|
406
|
+
}
|
|
407
|
+
// ONE CONVERSATION PER PERSON (decided 2026-10-01 from user research:
|
|
408
|
+
// people expect to click a name and see what was said before, and a
|
|
409
|
+
// subject line on every message between two coworkers makes talking
|
|
410
|
+
// feel like filing tickets). So a message goes on the end of the
|
|
411
|
+
// conversation the two of you already have, reopened if it was put away,
|
|
412
|
+
// and only the first message ever starts one. Work stays in threads; this
|
|
413
|
+
// is talk.
|
|
414
|
+
const title = firstSentence(text, 90) || text.slice(0, 90);
|
|
415
|
+
const convo = conversationIn(product.slug);
|
|
416
|
+
if (convo) {
|
|
417
|
+
store.answerItem(product.slug, convo.id, { answer: text });
|
|
418
|
+
// Who answers next is whoever did not speak last (shared/team-rules.mjs,
|
|
419
|
+
// `inMyInbox`); the assignee stays for a Mac on an older build.
|
|
420
|
+
store.teamPatch(product.slug, convo.id, { assignee: others[0] });
|
|
421
|
+
onChange();
|
|
422
|
+
await syncNow();
|
|
423
|
+
return store.readItem(product.slug, convo.id);
|
|
424
|
+
}
|
|
425
|
+
const level = Number.isFinite(priority) && priority > 0 ? Math.trunc(priority) : 0;
|
|
426
|
+
const item = store.composeItem(product.slug, { title, body: text, priority: level, assignee: others[0], people: [me, ...others] });
|
|
427
|
+
onChange();
|
|
428
|
+
await syncNow();
|
|
429
|
+
return item;
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
syncNow,
|
|
433
|
+
stop: stopLoop,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Where the service keeps its sync cursors: beside the store's own records.
|
|
438
|
+
export function teamStateFile(storeRoot) {
|
|
439
|
+
return path.join(storeRoot, '.team-sync.json');
|
|
440
|
+
}
|