agentbox-app 0.1.6
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/LICENSE +674 -0
- package/README.md +176 -0
- package/bin/agentbox.mjs +103 -0
- package/briefs/adhd-mode.md +5 -0
- package/briefs/message-rules.md +95 -0
- package/briefs/worker.md +230 -0
- package/briefs/writing-rules.md +336 -0
- package/main/account-discovery.mjs +145 -0
- package/main/account-tooling.mjs +261 -0
- package/main/agent-activity.mjs +56 -0
- package/main/agent-files.mjs +422 -0
- package/main/agent-schedule.mjs +254 -0
- package/main/agent-sessions.mjs +435 -0
- package/main/agents.mjs +1095 -0
- package/main/analytics.mjs +206 -0
- package/main/answer-modes.mjs +206 -0
- package/main/approval-prompt-server.mjs +160 -0
- package/main/approvals.mjs +487 -0
- package/main/artifact-path.mjs +144 -0
- package/main/claude-bin.mjs +311 -0
- package/main/claude-input.mjs +117 -0
- package/main/claude-models.mjs +244 -0
- package/main/claude-plan.mjs +148 -0
- package/main/claude-usage.mjs +149 -0
- package/main/code-change.mjs +383 -0
- package/main/code-file.mjs +116 -0
- package/main/codex-account.mjs +146 -0
- package/main/codex-app-server.mjs +796 -0
- package/main/codex-approvals.mjs +712 -0
- package/main/codex-bin.mjs +261 -0
- package/main/codex-compaction.mjs +49 -0
- package/main/codex-models.mjs +194 -0
- package/main/codex-session.mjs +797 -0
- package/main/codex-threads.mjs +268 -0
- package/main/codex-usage-file.mjs +192 -0
- package/main/codex-usage.mjs +149 -0
- package/main/codex-watch.mjs +35 -0
- package/main/codex.mjs +757 -0
- package/main/config.mjs +345 -0
- package/main/copy-selection.mjs +55 -0
- package/main/crash-report.mjs +158 -0
- package/main/demo.mjs +254 -0
- package/main/dev-window.mjs +103 -0
- package/main/doc-file.mjs +99 -0
- package/main/doc-scheme.mjs +126 -0
- package/main/first-run.mjs +190 -0
- package/main/folders.mjs +91 -0
- package/main/fresh-user.mjs +120 -0
- package/main/git-change.mjs +323 -0
- package/main/img-scheme.mjs +90 -0
- package/main/instruction-settings.mjs +205 -0
- package/main/ipc.mjs +1558 -0
- package/main/live-replies.mjs +77 -0
- package/main/machine.mjs +113 -0
- package/main/main.mjs +972 -0
- package/main/menu.mjs +177 -0
- package/main/notify.mjs +155 -0
- package/main/open-source-file.mjs +8 -0
- package/main/project-identity.mjs +149 -0
- package/main/provider-commands.mjs +32 -0
- package/main/rail-note.mjs +74 -0
- package/main/repeats.mjs +261 -0
- package/main/row-label.mjs +185 -0
- package/main/screen-detail.mjs +44 -0
- package/main/serve.mjs +262 -0
- package/main/settings.mjs +912 -0
- package/main/staleness.mjs +76 -0
- package/main/store/chats.mjs +188 -0
- package/main/store/home.mjs +383 -0
- package/main/store/migrate.mjs +40 -0
- package/main/store/paths.mjs +82 -0
- package/main/store/pending-writes.mjs +122 -0
- package/main/store/project-lock.mjs +200 -0
- package/main/store/project.mjs +523 -0
- package/main/store/queues.mjs +31 -0
- package/main/store/work-items.mjs +299 -0
- package/main/store/write-queue.mjs +14 -0
- package/main/store-modules.mjs +23 -0
- package/main/store.mjs +1269 -0
- package/main/supervisor.mjs +6875 -0
- package/main/task-commands.mjs +161 -0
- package/main/task-folders-offthread.mjs +84 -0
- package/main/task-folders-thread.mjs +13 -0
- package/main/task-folders.mjs +409 -0
- package/main/task-remote-control.mjs +65 -0
- package/main/task-terminals.mjs +169 -0
- package/main/terminal-access.mjs +6 -0
- package/main/updater.mjs +240 -0
- package/main/zoom-keys.mjs +54 -0
- package/main/zoom-readout.mjs +20 -0
- package/mcp/agentbox-mcp.sh +34 -0
- package/mcp/core/account.mjs +77 -0
- package/mcp/core/chat.mjs +61 -0
- package/mcp/core/documents.mjs +238 -0
- package/mcp/core/page.mjs +152 -0
- package/mcp/core/products.mjs +110 -0
- package/mcp/core/skills.mjs +126 -0
- package/mcp/core/work.mjs +377 -0
- package/mcp/stdio.mjs +70 -0
- package/mcp/tools.mjs +257 -0
- package/package.json +186 -0
- package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
- package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
- package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
- package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
- 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/gouache-valley-ChSu14-t.webp +0 -0
- package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
- package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.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-CwiDv5bL.css +32 -0
- package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
- 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/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +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/assets/xterm-BT7tBf8A.js +27 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- 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 +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +299 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
- package/renderer/dist/index.html +13 -0
- package/scripts/after-install.mjs +59 -0
- package/scripts/approval-server.sh +43 -0
- package/scripts/before-publish.mjs +133 -0
- package/shared/agent-import.d.mts +13 -0
- package/shared/agent-import.mjs +252 -0
- package/shared/agents.d.mts +190 -0
- package/shared/agents.mjs +937 -0
- package/shared/analytics-events.mjs +63 -0
- package/shared/answers.d.mts +12 -0
- package/shared/answers.mjs +80 -0
- package/shared/artifact-keys.d.mts +28 -0
- package/shared/artifact-keys.mjs +140 -0
- package/shared/bridge-map.mjs +128 -0
- package/shared/claude-commands.d.mts +20 -0
- package/shared/claude-commands.generated.mjs +149 -0
- package/shared/claude-commands.mjs +131 -0
- package/shared/claude-models.generated.d.mts +15 -0
- package/shared/claude-models.generated.mjs +26 -0
- package/shared/claude-usage.d.mts +16 -0
- package/shared/claude-usage.mjs +172 -0
- package/shared/codex-commands.d.mts +4 -0
- package/shared/codex-commands.mjs +17 -0
- package/shared/codex-import.d.mts +38 -0
- package/shared/codex-import.mjs +232 -0
- package/shared/codex-modes.mjs +159 -0
- package/shared/codex-usage.mjs +180 -0
- package/shared/contracts.mjs +412 -0
- package/shared/crash-scrub.mjs +182 -0
- package/shared/dashboard.mjs +481 -0
- package/shared/dead-run-trace.mjs +127 -0
- package/shared/demo-world.mjs +415 -0
- package/shared/effort-levels.d.mts +15 -0
- package/shared/effort-levels.mjs +76 -0
- package/shared/engines.d.mts +62 -0
- package/shared/engines.mjs +389 -0
- package/shared/first-run-examples.mjs +60 -0
- package/shared/first-run-line.mjs +149 -0
- package/shared/first-run-practice.d.mts +32 -0
- package/shared/first-run-practice.mjs +254 -0
- package/shared/first-run-shape.mjs +100 -0
- package/shared/fresh-user-home.mjs +189 -0
- package/shared/hook-failure.mjs +22 -0
- package/shared/install-id.mjs +39 -0
- package/shared/instruction-defaults.json +5 -0
- package/shared/library-folder.mjs +28 -0
- package/shared/notify-rules.mjs +120 -0
- package/shared/product-name.d.mts +6 -0
- package/shared/product-name.mjs +144 -0
- package/shared/project-folder-check.d.mts +22 -0
- package/shared/project-folder-check.mjs +152 -0
- package/shared/provider-commands.d.mts +8 -0
- package/shared/provider-commands.mjs +58 -0
- package/shared/rank.d.mts +19 -0
- package/shared/rank.mjs +146 -0
- package/shared/recovery.mjs +26 -0
- package/shared/referenced-files.d.mts +11 -0
- package/shared/referenced-files.mjs +122 -0
- package/shared/repeats.d.mts +45 -0
- package/shared/repeats.mjs +193 -0
- package/shared/schemes.mjs +33 -0
- package/shared/settings-terminal.d.mts +1 -0
- package/shared/settings-terminal.mjs +14 -0
- package/shared/side-build.mjs +57 -0
- package/shared/spawn-trouble.d.mts +8 -0
- package/shared/spawn-trouble.mjs +599 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +58 -0
- package/shared/usage.d.mts +53 -0
- package/shared/usage.mjs +319 -0
- package/shared/work-items.mjs +561 -0
- package/shared/work-lines.mjs +360 -0
- package/worker-permissions.json +6 -0
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// ONE FOLDER PER TASK, OWNED BY THE APP RATHER THAN BY THE AGENT IN IT.
|
|
2
|
+
//
|
|
3
|
+
// the founder, 2026-09-22, choosing between letting Claude Code make its own
|
|
4
|
+
// worktree and doing it ourselves: "the app makes the folder for both agents".
|
|
5
|
+
// The two engines are not equal here. Claude Code has isolation built in
|
|
6
|
+
// (`--worktree`), makes the folder under `.claude/worktrees/`, locks it, reuses
|
|
7
|
+
// it by name and deletes it when it leaves nothing behind. Codex has none of
|
|
8
|
+
// that, only `--cd` for choosing where to work. So the rules have to live on
|
|
9
|
+
// our side for one engine whatever we do, and two mechanisms would be two sets
|
|
10
|
+
// of failures to test. This is the one mechanism, and it puts the folder
|
|
11
|
+
// exactly where Claude Code would, so a person who opens the repository in a
|
|
12
|
+
// terminal sees the layout they already know.
|
|
13
|
+
//
|
|
14
|
+
// WHAT WAS MEASURED, ON HER MAC, 2026-09-22. This repository had 25 leftover
|
|
15
|
+
// agent copies holding 26 GB: twelve with edits committed nowhere, twelve on
|
|
16
|
+
// branches never merged, one safe to delete. Isolation was a rule in the worker
|
|
17
|
+
// brief; the rule's first half (make a copy) was kept and its second half (fold
|
|
18
|
+
// it back, delete the copy) was not, because the fold only happens once she
|
|
19
|
+
// approves and approval rarely lands in the hour the session dies. Nothing in
|
|
20
|
+
// the app knew those folders existed, so nothing could ever show them to her.
|
|
21
|
+
//
|
|
22
|
+
// THE THREE RULES THAT MATTER, and the reason each one is not negotiable:
|
|
23
|
+
//
|
|
24
|
+
// The name is the TASK, never the session. Her reply, live steering and
|
|
25
|
+
// remote control all have to land where the work already is, and a session id
|
|
26
|
+
// changes every time while a row id does not. It is also what keeps Claude
|
|
27
|
+
// Code's transcript path stable, since that path is derived from the cwd.
|
|
28
|
+
//
|
|
29
|
+
// A folder is deleted only when losing it costs nothing: clean, and its
|
|
30
|
+
// branch already in main. Anything else is KEPT AND REPORTED. A folder kept
|
|
31
|
+
// quietly is precisely what stranded twelve of hers.
|
|
32
|
+
//
|
|
33
|
+
// A folder a live session stands in is never touched. `git worktree remove
|
|
34
|
+
// --force` would happily pull the ground out from under a running agent, so
|
|
35
|
+
// the lock carries the owning pid and a release from anyone else checks
|
|
36
|
+
// whether that process is still alive.
|
|
37
|
+
//
|
|
38
|
+
// Branches are never deleted here. Every commit survives the folder, so the
|
|
39
|
+
// worst this can do is make somebody run `git worktree add` again.
|
|
40
|
+
import { execFileSync } from 'node:child_process';
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
import { nameSlug } from '../shared/product-name.mjs';
|
|
44
|
+
|
|
45
|
+
/** Where Claude Code puts its own, so both engines and a human agree. */
|
|
46
|
+
const WORKTREES = ['.claude', 'worktrees'];
|
|
47
|
+
|
|
48
|
+
/** The branch prefix this repository already uses for agent work. */
|
|
49
|
+
const BRANCH_PREFIX = `${nameSlug}/`;
|
|
50
|
+
|
|
51
|
+
/** Ids are `w-` plus hex today. This is the guard for the day they are not. */
|
|
52
|
+
const SAFE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
|
|
53
|
+
|
|
54
|
+
const git = (cwd, args) => execFileSync('git', args, {
|
|
55
|
+
cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
+
}).trim();
|
|
57
|
+
|
|
58
|
+
/** Git that answers rather than throws, for the questions where "no" is fine. */
|
|
59
|
+
function tryGit(cwd, args) {
|
|
60
|
+
try { return { ok: true, out: git(cwd, args) }; }
|
|
61
|
+
catch (error) { return { ok: false, out: String(error?.stderr ?? error?.message ?? '').trim() }; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function safeTaskName(id) {
|
|
65
|
+
const name = String(id ?? '');
|
|
66
|
+
if (!SAFE_NAME.test(name) || name.length > 64) {
|
|
67
|
+
throw Error(`Not a name a task folder may be given: ${JSON.stringify(id)}`);
|
|
68
|
+
}
|
|
69
|
+
return name;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Always through the real path. On a Mac `/tmp` and `/var` are symlinks into
|
|
74
|
+
* `/private`, and git answers with the resolved spelling, so a folder made by
|
|
75
|
+
* one spelling and looked for by the other is two different folders as far as
|
|
76
|
+
* every comparison in the supervisor is concerned.
|
|
77
|
+
*/
|
|
78
|
+
export const real = (dir) => { try { return fs.realpathSync(dir); } catch { return dir; } };
|
|
79
|
+
|
|
80
|
+
export function taskFolderPath(repoPath, id) {
|
|
81
|
+
return path.join(real(repoPath), ...WORKTREES, safeTaskName(id));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function taskBranch(id) { return `${BRANCH_PREFIX}${safeTaskName(id)}`; }
|
|
85
|
+
|
|
86
|
+
/** The repository root, or null when this is not a checkout at all. */
|
|
87
|
+
export function repoRoot(dir) {
|
|
88
|
+
if (!dir || !fs.existsSync(dir)) return null;
|
|
89
|
+
const found = tryGit(dir, ['rev-parse', '--show-toplevel']);
|
|
90
|
+
return found.ok && found.out ? found.out : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What a new folder starts from: LOCAL main, not the remote. Local main carries
|
|
95
|
+
* whatever has not been pushed, and on this Mac that is routinely 20+ commits,
|
|
96
|
+
* so branching from origin would start every task without the last day of work.
|
|
97
|
+
*/
|
|
98
|
+
export function baseRef(repoPath) {
|
|
99
|
+
for (const name of ['main', 'master']) {
|
|
100
|
+
if (tryGit(repoPath, ['rev-parse', '--verify', '-q', `refs/heads/${name}`]).ok) return `refs/heads/${name}`;
|
|
101
|
+
}
|
|
102
|
+
return 'HEAD';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lockReason = (pid) => `agentbox pid=${pid}`;
|
|
106
|
+
const pidInReason = (reason) => {
|
|
107
|
+
const found = /agentbox pid=(\d+)/.exec(reason ?? '');
|
|
108
|
+
return found ? Number(found[1]) : null;
|
|
109
|
+
};
|
|
110
|
+
// Signal 0 asks the kernel whether a pid exists without touching it. EPERM
|
|
111
|
+
// means it exists and belongs to somebody else, which still counts as alive.
|
|
112
|
+
const alive = (pid) => {
|
|
113
|
+
if (!pid) return false;
|
|
114
|
+
try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Every worktree git knows about, as records rather than as text. */
|
|
118
|
+
function registered(repoPath) {
|
|
119
|
+
const listed = tryGit(repoPath, ['worktree', 'list', '--porcelain']);
|
|
120
|
+
if (!listed.ok) return [];
|
|
121
|
+
const out = [];
|
|
122
|
+
let current = null;
|
|
123
|
+
for (const line of listed.out.split('\n')) {
|
|
124
|
+
if (line.startsWith('worktree ')) { if (current) out.push(current); current = { path: line.slice(9) }; }
|
|
125
|
+
else if (line.startsWith('branch ') && current) current.branch = line.slice(7).replace(/^refs\/heads\//, '');
|
|
126
|
+
else if (line.startsWith('locked') && current) current.lock = line.slice(6).trim();
|
|
127
|
+
else if (line === 'prunable' && current) current.prunable = true;
|
|
128
|
+
}
|
|
129
|
+
if (current) out.push(current);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const record = (repoPath, folder) => registered(repoPath).find((w) => w.path === folder) ?? null;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The folder this task works in, made if it is not there yet. those
|
|
137
|
+
* sessions keep running exactly where they ran before.
|
|
138
|
+
*/
|
|
139
|
+
export function ensureTaskFolder(dir, id, { pid = process.pid, dependencies = true } = {}) {
|
|
140
|
+
const name = safeTaskName(id);
|
|
141
|
+
const root = repoRoot(dir);
|
|
142
|
+
if (!root) return null;
|
|
143
|
+
const folder = taskFolderPath(root, name);
|
|
144
|
+
const branch = taskBranch(name);
|
|
145
|
+
|
|
146
|
+
if (fs.existsSync(folder) && record(root, folder)) {
|
|
147
|
+
lock(root, folder, pid);
|
|
148
|
+
return { path: folder, branch: record(root, folder)?.branch ?? branch, created: false };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// A registration whose directory somebody deleted by hand would refuse the
|
|
152
|
+
// add below with "already registered", so clear those first. `prune` only
|
|
153
|
+
// ever drops registrations whose folder is already gone.
|
|
154
|
+
tryGit(root, ['worktree', 'prune']);
|
|
155
|
+
fs.mkdirSync(path.dirname(folder), { recursive: true });
|
|
156
|
+
|
|
157
|
+
hideFromTheCheckout(root);
|
|
158
|
+
|
|
159
|
+
const branchExists = tryGit(root, ['rev-parse', '--verify', '-q', `refs/heads/${branch}`]).ok;
|
|
160
|
+
const from = branchExists ? branch : baseRef(root);
|
|
161
|
+
if (!cloneCheckout(root, folder, branch, from, branchExists)) {
|
|
162
|
+
const add = branchExists
|
|
163
|
+
? tryGit(root, ['worktree', 'add', folder, branch])
|
|
164
|
+
: tryGit(root, ['worktree', 'add', '-b', branch, folder, baseRef(root)]);
|
|
165
|
+
if (!add.ok) throw Error(`Could not make a folder for ${name}: ${add.out}`);
|
|
166
|
+
if (dependencies) cloneDependencies(root, folder);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
lock(root, folder, pid);
|
|
170
|
+
return { path: folder, branch, created: true };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* MAKING THE FOLDER WITHOUT PAYING FOR IT TWICE.
|
|
175
|
+
*
|
|
176
|
+
* Measured on her repository, 2026-09-22: a plain `git worktree add` takes 23
|
|
177
|
+
* seconds and writes 960 MB, because 634 MB of this checkout is the archived
|
|
178
|
+
* screenshots in `shots/` and no agent ever opens one. Twenty five of those
|
|
179
|
+
* folders is most of the 26 GB she was carrying.
|
|
180
|
+
*
|
|
181
|
+
* So when the checkout beside us is already standing on the commit the folder
|
|
182
|
+
* starts from, and is clean, the files are cloned from it instead: on APFS that
|
|
183
|
+
* is a block-sharing clone, which measured 5 seconds and no measurable disk at
|
|
184
|
+
* all for the same folder. `--no-checkout` leaves the index empty, so a `reset`
|
|
185
|
+
* fills it in from the commit without writing a single file.
|
|
186
|
+
*
|
|
187
|
+
* IT IS CHECKED RATHER THAN TRUSTED. If the result is not identical to the
|
|
188
|
+
* commit, down to an empty `git status`, the folder is thrown away and the
|
|
189
|
+
* ordinary checkout runs. A fast folder that is subtly not the code would be
|
|
190
|
+
* far worse than a slow one.
|
|
191
|
+
*/
|
|
192
|
+
function cloneCheckout(root, folder, branch, from, branchExists) {
|
|
193
|
+
// TRACKED CONTENT ONLY, on both sides of this. Her checkout permanently
|
|
194
|
+
// carries a couple of untracked files (a scratch `:memory:.ses`, a folder of
|
|
195
|
+
// screenshots somebody left), and counting those as dirty turned the fast
|
|
196
|
+
// path off on the one repository it was written for: measured 2026-09-22,
|
|
197
|
+
// 979 MB and 7 seconds for a folder that should have cost neither. Untracked
|
|
198
|
+
// files ride along into the new folder, which is what a worker wants anyway.
|
|
199
|
+
const head = tryGit(root, ['rev-parse', 'HEAD']);
|
|
200
|
+
const target = tryGit(root, ['rev-parse', from]);
|
|
201
|
+
const clean = tryGit(root, ['status', '--porcelain', '--untracked-files=no']);
|
|
202
|
+
if (!head.ok || !target.ok || head.out !== target.out) return false;
|
|
203
|
+
if (!clean.ok || clean.out.length > 0) return false;
|
|
204
|
+
|
|
205
|
+
const add = branchExists
|
|
206
|
+
? tryGit(root, ['worktree', 'add', '--no-checkout', folder, branch])
|
|
207
|
+
: tryGit(root, ['worktree', 'add', '--no-checkout', '-b', branch, folder, from]);
|
|
208
|
+
if (!add.ok) return false;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
for (const entry of fs.readdirSync(root)) {
|
|
212
|
+
if (entry === '.git' || entry === WORKTREES[0]) continue;
|
|
213
|
+
execFileSync('cp', ['-c', '-R', path.join(root, entry), folder], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
214
|
+
}
|
|
215
|
+
if (!tryGit(folder, ['reset', '-q', 'HEAD']).ok) throw Error('the index would not fill in');
|
|
216
|
+
const after = tryGit(folder, ['status', '--porcelain', '--untracked-files=no']);
|
|
217
|
+
if (!after.ok || after.out.length > 0) throw Error('the clone did not match the commit');
|
|
218
|
+
return true;
|
|
219
|
+
} catch {
|
|
220
|
+
// Back to nothing, so the ordinary path starts from a clean sheet.
|
|
221
|
+
tryGit(root, ['worktree', 'remove', '--force', folder]);
|
|
222
|
+
try { fs.rmSync(folder, { recursive: true, force: true }); } catch { /* already gone */ }
|
|
223
|
+
tryGit(root, ['worktree', 'prune']);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Task folders live inside the repository, where the checkout would otherwise
|
|
230
|
+
* report them as untracked and every review card built in the shared checkout
|
|
231
|
+
* would carry them. `.git/info/exclude` rather than `.gitignore`, because this
|
|
232
|
+
* is a fact about this machine and not a line to commit into her project.
|
|
233
|
+
*/
|
|
234
|
+
function hideFromTheCheckout(root) {
|
|
235
|
+
const line = `${WORKTREES.join('/')}/`;
|
|
236
|
+
const gitDir = tryGit(root, ['rev-parse', '--git-common-dir']);
|
|
237
|
+
if (!gitDir.ok) return;
|
|
238
|
+
const file = path.resolve(root, gitDir.out, 'info', 'exclude');
|
|
239
|
+
try {
|
|
240
|
+
const held = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
241
|
+
if (held.split('\n').includes(line)) return;
|
|
242
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
243
|
+
fs.writeFileSync(file, `${held}${held.endsWith('\n') || held === '' ? '' : '\n'}${line}\n`);
|
|
244
|
+
} catch { /* an unwritable git dir is not a reason to refuse the folder */ }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Dependencies, cloned rather than copied. Measured 2026-09-22: `cp -c` of this
|
|
249
|
+
* repository's 929 MB node_modules took 2.9 seconds and moved the volume's free
|
|
250
|
+
* space by nothing at all, because APFS shares the blocks until something
|
|
251
|
+
* writes. A plain copy is a minute and a real gigabyte, which is most of what
|
|
252
|
+
* the 26 GB on her Mac was. Failure here is never fatal: the folder is still a
|
|
253
|
+
* good folder, it just has to install for itself.
|
|
254
|
+
*/
|
|
255
|
+
function cloneDependencies(root, folder) {
|
|
256
|
+
const from = path.join(root, 'node_modules');
|
|
257
|
+
const to = path.join(folder, 'node_modules');
|
|
258
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) return false;
|
|
259
|
+
try {
|
|
260
|
+
execFileSync('cp', ['-c', '-R', from, to], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
261
|
+
return true;
|
|
262
|
+
} catch {
|
|
263
|
+
try { fs.rmSync(to, { recursive: true, force: true }); } catch { /* nothing to undo */ }
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function lock(root, folder, pid) {
|
|
269
|
+
const held = record(root, folder)?.lock;
|
|
270
|
+
if (held !== undefined && pidInReason(held) !== pid) return;
|
|
271
|
+
if (held === undefined) tryGit(root, ['worktree', 'lock', '--reason', lockReason(pid), folder]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Is there anything in here that deleting the folder would lose? */
|
|
275
|
+
export function folderState(root, folder) {
|
|
276
|
+
const status = tryGit(folder, ['status', '--porcelain']);
|
|
277
|
+
const dirty = !status.ok || status.out.length > 0;
|
|
278
|
+
const head = tryGit(folder, ['rev-parse', 'HEAD']);
|
|
279
|
+
const merged = head.ok
|
|
280
|
+
&& tryGit(root, ['merge-base', '--is-ancestor', head.out, baseRef(root)]).ok;
|
|
281
|
+
return { dirty, merged };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Give the folder back when the session is done with it. Removal is the rare
|
|
286
|
+
* case on purpose: it happens when the work is already in main and nothing is
|
|
287
|
+
* uncommitted. Every other answer names what is in the way, so the app can say
|
|
288
|
+
* it rather than leave another invisible folder on her disk.
|
|
289
|
+
*/
|
|
290
|
+
export function releaseTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
291
|
+
const name = safeTaskName(id);
|
|
292
|
+
const root = repoRoot(dir);
|
|
293
|
+
if (!root) return { removed: false, reason: 'no-repo' };
|
|
294
|
+
const folder = taskFolderPath(root, name);
|
|
295
|
+
if (!fs.existsSync(folder)) return { removed: false, reason: 'gone' };
|
|
296
|
+
|
|
297
|
+
const holder = pidInReason(record(root, folder)?.lock);
|
|
298
|
+
if (holder && holder !== pid && alive(holder)) return { removed: false, reason: 'held', path: folder };
|
|
299
|
+
|
|
300
|
+
const { dirty, merged } = folderState(root, folder);
|
|
301
|
+
if (dirty) return { removed: false, reason: 'uncommitted', path: folder };
|
|
302
|
+
if (!merged) return { removed: false, reason: 'unmerged', path: folder };
|
|
303
|
+
|
|
304
|
+
tryGit(root, ['worktree', 'unlock', folder]);
|
|
305
|
+
// No --force: git refuses a second time if anything is uncommitted, which is
|
|
306
|
+
// the belt under this braces.
|
|
307
|
+
const removed = tryGit(root, ['worktree', 'remove', folder]);
|
|
308
|
+
if (!removed.ok) return { removed: false, reason: 'refused', path: folder, detail: removed.out };
|
|
309
|
+
return { removed: true, reason: 'merged', path: folder };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The subject line that marks a commit as work parked rather than finished. */
|
|
313
|
+
export const PARKED = 'Parked when the task was closed';
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* CLOSING A TASK PARKS ITS FOLDER, AND PARKING LOSES NOTHING.
|
|
317
|
+
*
|
|
318
|
+
* A closed row's folder is worth reclaiming, but a row can be closed by
|
|
319
|
+
* accident, and a row closed on the understanding that it can be picked up
|
|
320
|
+
* again later is not really finished with its code.
|
|
321
|
+
*
|
|
322
|
+
* The obvious answer is a window before deletion, and a window is the wrong
|
|
323
|
+
* shape. A
|
|
324
|
+
* timer deletes work while she is not looking, which is the exact outcome it
|
|
325
|
+
* exists to prevent, and it has to be tuned against a case she calls a rarity.
|
|
326
|
+
* What actually makes a folder disposable is that nothing in it exists only
|
|
327
|
+
* there: the branch holds every commit. So the only thing at risk is what an
|
|
328
|
+
* agent left uncommitted, and the answer is to commit that before letting go.
|
|
329
|
+
*
|
|
330
|
+
* Parking therefore means: commit whatever is uncommitted onto the task's own
|
|
331
|
+
* branch, then remove the folder. `restoreTaskFolder` undoes that commit, so a
|
|
332
|
+
* reopened task comes back to the files it left, staged exactly as they were.
|
|
333
|
+
* The branch is never deleted, by this or by anything else in this file, so an
|
|
334
|
+
* accidental close costs the ten seconds it takes to make the folder again.
|
|
335
|
+
*
|
|
336
|
+
* WHAT IT DOES NOT KEEP, said plainly: files git is told to ignore. Dependencies
|
|
337
|
+
* are cloned fresh on the way back, which is most of them, but a `.env` an agent
|
|
338
|
+
* wrote itself goes. That is the same boundary as removing any worktree, and the
|
|
339
|
+
* alternative is committing secrets onto a branch.
|
|
340
|
+
*/
|
|
341
|
+
export function parkTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
342
|
+
const name = safeTaskName(id);
|
|
343
|
+
const root = repoRoot(dir);
|
|
344
|
+
if (!root) return { parked: false, reason: 'no-repo' };
|
|
345
|
+
const folder = taskFolderPath(root, name);
|
|
346
|
+
if (!fs.existsSync(folder)) return { parked: false, reason: 'gone' };
|
|
347
|
+
|
|
348
|
+
const holder = pidInReason(record(root, folder)?.lock);
|
|
349
|
+
if (holder && holder !== pid && alive(holder)) return { parked: false, reason: 'held', path: folder };
|
|
350
|
+
|
|
351
|
+
const { dirty } = folderState(root, folder);
|
|
352
|
+
let kept = 'nothing uncommitted';
|
|
353
|
+
if (dirty) {
|
|
354
|
+
const staged = tryGit(folder, ['add', '-A']);
|
|
355
|
+
// `--no-verify`, because a repository's own commit hooks are written for a
|
|
356
|
+
// person finishing a piece of work, and this is the app tidying up after
|
|
357
|
+
// one. A hook that refuses here would strand the folder it is protecting.
|
|
358
|
+
const held = tryGit(folder, ['commit', '--no-verify', '-q', '-m', PARKED]);
|
|
359
|
+
if (!staged.ok || !held.ok) return { parked: false, reason: 'refused', path: folder, detail: held.out };
|
|
360
|
+
kept = 'work in progress';
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
tryGit(root, ['worktree', 'unlock', folder]);
|
|
364
|
+
const removed = tryGit(root, ['worktree', 'remove', folder]);
|
|
365
|
+
if (!removed.ok) return { parked: false, reason: 'refused', path: folder, detail: removed.out };
|
|
366
|
+
return { parked: true, kept, path: folder };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* The way back. Makes the folder again off the branch, and when the last commit
|
|
371
|
+
* on it is a parked one, takes that commit apart so the work is uncommitted
|
|
372
|
+
* exactly as it was. Undoing it rather than leaving it is what stops a row she
|
|
373
|
+
* opens and closes five times from growing five commits nobody wrote.
|
|
374
|
+
*/
|
|
375
|
+
export function restoreTaskFolder(dir, id, { pid = process.pid } = {}) {
|
|
376
|
+
const made = ensureTaskFolder(dir, id, { pid });
|
|
377
|
+
if (!made) return null;
|
|
378
|
+
const last = tryGit(made.path, ['log', '-1', '--format=%s']);
|
|
379
|
+
if (last.ok && last.out === PARKED) tryGit(made.path, ['reset', '-q', 'HEAD~1']);
|
|
380
|
+
return made;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Every task folder in this repository and what would be lost by deleting it.
|
|
385
|
+
* This is what the app reads to show her a folder that is holding work, which
|
|
386
|
+
* is the half that did not exist: hers held 26 GB and she had never seen one.
|
|
387
|
+
*/
|
|
388
|
+
export function listTaskFolders(dir) {
|
|
389
|
+
const root = repoRoot(dir);
|
|
390
|
+
if (!root) return [];
|
|
391
|
+
const home = path.join(root, ...WORKTREES);
|
|
392
|
+
return registered(root)
|
|
393
|
+
.filter((w) => path.dirname(w.path) === home && !w.prunable)
|
|
394
|
+
.map((w) => {
|
|
395
|
+
const { dirty, merged } = folderState(root, w.path);
|
|
396
|
+
let touchedAt = null;
|
|
397
|
+
try { touchedAt = fs.statSync(w.path).mtimeMs; } catch { /* it can vanish under us */ }
|
|
398
|
+
const holder = pidInReason(w.lock);
|
|
399
|
+
return {
|
|
400
|
+
id: path.basename(w.path),
|
|
401
|
+
path: w.path,
|
|
402
|
+
branch: w.branch ?? '',
|
|
403
|
+
dirty,
|
|
404
|
+
merged,
|
|
405
|
+
touchedAt,
|
|
406
|
+
heldBy: holder && alive(holder) ? holder : null,
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Remote registration belongs to the existing task process and account.
|
|
2
|
+
// No links or enabled state survive an app restart without a live provider.
|
|
3
|
+
import { Name } from '../shared/product-name.mjs';
|
|
4
|
+
|
|
5
|
+
export function remoteSessionUrl(value) {
|
|
6
|
+
try { const url=new URL(value); return url.protocol==='https:'&&url.hostname==='claude.ai'&&!url.username&&!url.password&&!url.port&&/^\/code\/session_[A-Za-z0-9]+$/.test(url.pathname)&&!url.search&&!url.hash ? url.href : null; } catch { return null; }
|
|
7
|
+
}
|
|
8
|
+
export function remoteStatus(sup,product,id) { return sup._remoteControls?.get(JSON.stringify([product,id]))??null; }
|
|
9
|
+
export async function taskRemoteControl(sup,product,id,action='status') {
|
|
10
|
+
const key=JSON.stringify([product,id]);
|
|
11
|
+
sup._remoteControls??=new Map();
|
|
12
|
+
const current=()=>sup._remoteControls.get(key);
|
|
13
|
+
const publish=(state,extra={})=>{const value={state,at:Date.now(),...extra};sup._remoteControls.set(key,value);sup.onChange?.();return value;};
|
|
14
|
+
const item=sup.store.readItem(product,id);
|
|
15
|
+
if(!item||item.agent)throw Error('This task is not available.');
|
|
16
|
+
if(action==='status')return current()??null;
|
|
17
|
+
if(!['toggle','dismiss','enable','disable'].includes(action))throw Error('Unknown remote-control action.');
|
|
18
|
+
if(sup._engineFor(item)==='codex')throw Error('Remote Control is currently available for Claude Code conversations only.');
|
|
19
|
+
if(action==='dismiss')return current()?.state==='failed'&&!current()?.mayBeActive?publish('off'):current();
|
|
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
|
+
// Her words, w-79bffd7ec3: "should trigger it to turn on or off, depending on
|
|
23
|
+
// its current state, without multiple confirmations."
|
|
24
|
+
if(action==='toggle')action=['connected','ready','reconnecting'].includes(current()?.state)||current()?.mayBeActive?'disable':'enable';
|
|
25
|
+
if(action==='enable'&&['connected','ready','reconnecting'].includes(current()?.state))return current();
|
|
26
|
+
let session=sup.sessions.get(id);
|
|
27
|
+
if(session&&session.product!==product)throw Error('The active session belongs to a different project.');
|
|
28
|
+
if(action==='disable') {
|
|
29
|
+
if(!session?.remoteHeld)return publish('off');
|
|
30
|
+
publish('disconnecting');
|
|
31
|
+
try { await session.child.control({subtype:'remote_control',enabled:false});session.remoteHeld=false;session.child.holdInput(false);if(session.remoteIdle&&session.child.stdin.writable)session.child.stdin.end();return publish('off'); }
|
|
32
|
+
catch(error){return publish('failed',{text:error.message,mayBeActive:true});}
|
|
33
|
+
}
|
|
34
|
+
const project=sup.store.listProducts().find(p=>p.slug===product);
|
|
35
|
+
const record=sup.rowSessionFor(item,project);
|
|
36
|
+
if(!session&&!record?.sessionId)return publish('failed',{text:'Send a message in this task first, then enable Remote Control.'});
|
|
37
|
+
publish('connecting');
|
|
38
|
+
try {
|
|
39
|
+
if(!session){sup.spawnWorker({...item,product},{continuation:true,resumeSessionId:record.sessionId,profile:record.profile,engine:'claude',remoteOnly:true});session=sup.sessions.get(id);}
|
|
40
|
+
if(!session?.child?.control)throw Error('This conversation could not start. Check its account and try again.');
|
|
41
|
+
session.child.holdInput(true);session.remoteHeld=true;
|
|
42
|
+
if(!session.remoteListeners){
|
|
43
|
+
session.remoteListeners=true;
|
|
44
|
+
session.child.on('bridge-state',event=>{
|
|
45
|
+
if(!session.remoteHeld||['disconnecting','off'].includes(current()?.state))return;
|
|
46
|
+
if(current()?.epoch!=null&&event.bridge_epoch!=null&¤t().epoch!==event.bridge_epoch)return;
|
|
47
|
+
const state=['connected','ready','reconnecting'].includes(event.state)?event.state:event.state==='failed'||event.state==='policy_disabled'?'failed':'reconnecting';
|
|
48
|
+
publish(state,{url:current()?.url,epoch:event.bridge_epoch,text:event.detail,mayBeActive:true});
|
|
49
|
+
});
|
|
50
|
+
session.child.once('exit',()=>publish('off',{text:'The local session ended. Run /remote-control to reconnect.'}));
|
|
51
|
+
}
|
|
52
|
+
session.remoteInit??=session.child.control({subtype:'initialize',hooks:{}});
|
|
53
|
+
await session.remoteInit;
|
|
54
|
+
const result=await session.child.control({subtype:'remote_control',enabled:true,name:item.title||`${Name} conversation`});
|
|
55
|
+
const url=remoteSessionUrl(result.session_url);
|
|
56
|
+
if(!url)throw Error('Claude did not return a valid remote session link.');
|
|
57
|
+
return publish('ready',{url,epoch:result.bridge_epoch,mayBeActive:true});
|
|
58
|
+
} catch(error) {
|
|
59
|
+
// A timeout is not proof that registration failed. Explicitly turn it off;
|
|
60
|
+
// if that cannot be confirmed, keep a visible stop action and the process.
|
|
61
|
+
let mayBeActive=false;
|
|
62
|
+
if(session?.remoteHeld){try{await session.child.control({subtype:'remote_control',enabled:false},5000);session.remoteHeld=false;session.child.holdInput(false);if(session.remoteIdle&&session.child.stdin.writable)session.child.stdin.end();}catch{mayBeActive=true;}}
|
|
63
|
+
return publish('failed',{text:error.message,mayBeActive});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// User-owned shells. Never provider tool calls; no transcript or analytics writes.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import {randomUUID} from 'node:crypto';
|
|
4
|
+
import {execFileSync} from 'node:child_process';
|
|
5
|
+
import {createRequire} from 'node:module';
|
|
6
|
+
import {commandRunning} from '../shared/terminal-state.mjs';
|
|
7
|
+
const require=createRequire(import.meta.url);
|
|
8
|
+
const LIMIT=262144;
|
|
9
|
+
export function terminalEnv(source=process.env){
|
|
10
|
+
const env={...source,TERM:'xterm-256color',COLORTERM:'truecolor'};
|
|
11
|
+
for(const key of Object.keys(env)) if(/^(ANTHROPIC_|OPENAI_API_KEY$|CLAUDE_CODE_|CLAUDECODE$|CLAUDE_PID$|CLAUDE_EFFORT$|ELECTRON_|NODE_OPTIONS$)/.test(key)) delete env[key];
|
|
12
|
+
return env;
|
|
13
|
+
}
|
|
14
|
+
/*
|
|
15
|
+
* A TASK'S OWN CHECKOUT IS DELETED WHEN ITS BRANCH IS MERGED, and the row keeps
|
|
16
|
+
remembering it. That is the normal end of a task, not a broken one, and it
|
|
17
|
+
used to leave the terminal refusing to open at all (w-566a2e749f, on the
|
|
18
|
+
step where she was told to run commands in that very project). So the
|
|
19
|
+
terminal opens in the project folder instead, and says so in its first line,
|
|
20
|
+
because a shell quietly sitting somewhere other than the task's folder is the
|
|
21
|
+
thing this file has always refused to do. */
|
|
22
|
+
export function terminalPlace({store,supervisor,product,id,agent}){
|
|
23
|
+
const item=store.listItems().find(i=>i.id===id&&i.product===product);
|
|
24
|
+
if(!item&&!agent) throw Error('This task is no longer available.');
|
|
25
|
+
const prod=store.listProducts().find(p=>p.slug===product);
|
|
26
|
+
const rec=supervisor._liveSessions?.[id] ?? supervisor._rowSessions?.[id];
|
|
27
|
+
const isDir=p=>!!p&&fs.existsSync(p)&&fs.statSync(p).isDirectory();
|
|
28
|
+
const own=agent?.cwd ?? (rec?.product===product?rec.cwd:null);
|
|
29
|
+
const cwd=[own,prod?.repoPath,prod?.dir].find(isDir);
|
|
30
|
+
if(!cwd) throw Error('This task’s working folder is unavailable. Restore it or update the project folder.');
|
|
31
|
+
const notice=own&&cwd!==own?`This task’s own folder is gone, usually because its work was merged. Opened in the project folder, ${cwd}.`:undefined;
|
|
32
|
+
return {cwd,notice};
|
|
33
|
+
}
|
|
34
|
+
export function terminalFolder(args){return terminalPlace(args).cwd;}
|
|
35
|
+
// Enumerate only descendants of our own shell, including background process
|
|
36
|
+
// groups. Kill children before shell; closing a pane never calls this.
|
|
37
|
+
export function disposeTerminalProcess(pty){
|
|
38
|
+
const descendants=[];
|
|
39
|
+
try{
|
|
40
|
+
const rows=execFileSync('/bin/ps',['-axo','pid=,ppid='],{encoding:'utf8',timeout:2000}).trim().split('\n').map(s=>s.trim().split(/\s+/).map(Number));
|
|
41
|
+
const parents=new Set([pty.pid]);
|
|
42
|
+
for(let found=true;found;){found=false;for(const [pid,ppid] of rows)if(parents.has(ppid)&&!parents.has(pid)){parents.add(pid);descendants.push(pid);found=true;}}
|
|
43
|
+
}catch{}
|
|
44
|
+
for(const pid of descendants.reverse())try{process.kill(pid,'SIGKILL');}catch{}
|
|
45
|
+
try{pty.kill('SIGKILL');}catch{}
|
|
46
|
+
}
|
|
47
|
+
/*
|
|
48
|
+
* NOBODY ENDS A SHELL, SO THE SHELLS END THEMSELVES.
|
|
49
|
+
*
|
|
50
|
+
* She picked this shape out of three the next day: "Ten minute idle timer, plus
|
|
51
|
+
* reuse the oldest idle slot at the cap."
|
|
52
|
+
*
|
|
53
|
+
* TWO FACTS THE APP ALREADY HAD, and the whole design falls out of them.
|
|
54
|
+
*
|
|
55
|
+
* WHICH SHELLS ARE WORTH KEEPING: one with a command in it. `commandRunning`
|
|
56
|
+
* (../shared/terminal-state.mjs) reads node-pty's foreground process name, and
|
|
57
|
+
* it is the same call the toolbar makes to decide whether to draw Stop command,
|
|
58
|
+
* deliberately, so a shell she can see is busy can never be one this reaps.
|
|
59
|
+
*
|
|
60
|
+
* WHICH SHELLS NOBODY IS WATCHING: the panel asks for output every 150ms while
|
|
61
|
+
* it is on screen and stops the instant it is hidden or she leaves the task
|
|
62
|
+
* (TaskTerminal.tsx). So `lastReadAt` is not a guess about attention, it is the
|
|
63
|
+
* last time the app was actually asked what this shell had to say.
|
|
64
|
+
*
|
|
65
|
+
* Idle AND unwatched for ten minutes is a shell that costs something and is
|
|
66
|
+
* worth nothing, and it goes. Everything else stays until she or the app quits.
|
|
67
|
+
*/
|
|
68
|
+
const IDLE_MS=600000;
|
|
69
|
+
const SWEEP_MS=60000;
|
|
70
|
+
const MAX_TERMINALS=20;
|
|
71
|
+
export class TaskTerminals {
|
|
72
|
+
constructor({spawn,resolve,disposeProcess=disposeTerminalProcess,env=terminalEnv(),shell=process.env.SHELL||'/bin/zsh',idleMs=IDLE_MS,sweepMs=SWEEP_MS,now=()=>Date.now()}={}){
|
|
73
|
+
this.spawn=spawn??((...args)=>require('node-pty').spawn(...args));this.resolve=resolve;this.disposeProcess=disposeProcess;this.env=env;this.shell=shell;this.sessions=new Map();
|
|
74
|
+
this.idleMs=idleMs;this.now=now;
|
|
75
|
+
// Keys whose shell this class ended on its own, and WHY. The next open on
|
|
76
|
+
// one says so rather than handing her a blank screen where her scrollback
|
|
77
|
+
// was, and the two reasons are different sentences because they are
|
|
78
|
+
// different events: one is time passing, the other is her opening a
|
|
79
|
+
// twenty-first terminal.
|
|
80
|
+
this.reaped=new Map();
|
|
81
|
+
// `unref` so a sweep that is merely pending never holds the app open, and
|
|
82
|
+
// never in a test: a suite that leaves an interval behind hangs the runner.
|
|
83
|
+
if(sweepMs>0&&typeof setInterval==='function'){
|
|
84
|
+
this.timer=setInterval(()=>{try{this.sweepIdle();}catch{}},sweepMs);
|
|
85
|
+
this.timer?.unref?.();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/*
|
|
89
|
+
* WHETHER THIS SHELL IS DOING ANYTHING. Wrapped, because `pty.process` is a
|
|
90
|
+
getter onto a real process that may have gone between the sweep deciding to
|
|
91
|
+
look and the kernel answering; a throw there must read as idle-and-dead
|
|
92
|
+
rather than take the sweep down with it. */
|
|
93
|
+
busy(session){
|
|
94
|
+
if(session.exited)return false;
|
|
95
|
+
try{return commandRunning(session.pty.process||'Shell',false);}catch{return false;}
|
|
96
|
+
}
|
|
97
|
+
/*
|
|
98
|
+
* THE ONE THE CAP TAKES: least recently watched first, and never a busy one.
|
|
99
|
+
Null means every slot is genuinely working, which is the only case left
|
|
100
|
+
where she is told no. */
|
|
101
|
+
oldestIdle(){
|
|
102
|
+
let pick=null,at=Infinity;
|
|
103
|
+
for(const [key,s] of this.sessions){
|
|
104
|
+
if(this.busy(s))continue;
|
|
105
|
+
const seen=s.lastReadAt??0;
|
|
106
|
+
if(seen<at){at=seen;pick=key;}
|
|
107
|
+
}
|
|
108
|
+
return pick;
|
|
109
|
+
}
|
|
110
|
+
sweepIdle(){
|
|
111
|
+
const cutoff=this.now()-this.idleMs;
|
|
112
|
+
for(const [key,s] of [...this.sessions]){
|
|
113
|
+
if(this.busy(s))continue;
|
|
114
|
+
if((s.lastReadAt??0)>cutoff)continue;
|
|
115
|
+
this.close(key);
|
|
116
|
+
this.reaped.set(key,'The previous shell here was closed after ten minutes idle.');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
open(key){
|
|
120
|
+
if(this.sessions.has(key))return this.read(key,0);
|
|
121
|
+
if(this.sessions.size>=MAX_TERMINALS){
|
|
122
|
+
/*
|
|
123
|
+
* THE CAP REUSES BEFORE IT REFUSES. It used to throw here on the 21st, and
|
|
124
|
+
the sentence told her to end a session, which is the one thing she has
|
|
125
|
+
said she never does. Now the least recently watched IDLE shell makes
|
|
126
|
+
room, and the refusal is kept for the only state it is still true in:
|
|
127
|
+
twenty shells all running something, which is worth interrupting for
|
|
128
|
+
and says so in those words. */
|
|
129
|
+
const victim=this.oldestIdle();
|
|
130
|
+
if(victim===null)throw Error('20 terminals are open and every one of them is running something. Stop one of those commands before opening another terminal.');
|
|
131
|
+
this.close(victim);
|
|
132
|
+
this.reaped.set(victim,'This shell was closed to make room for a newer terminal.');
|
|
133
|
+
}
|
|
134
|
+
const place=this.resolve(key);
|
|
135
|
+
const cwd=typeof place==='string'?place:place.cwd;
|
|
136
|
+
const pty=this.spawn(this.shell,['-l'],{cwd,env:this.env,cols:80,rows:24,name:'xterm-256color'});
|
|
137
|
+
let resolveExit;const exitPromise=new Promise(resolve=>{resolveExit=resolve;});
|
|
138
|
+
const session={token:randomUUID(),pty,cwd,buffer:'',offset:0,exitCode:null,exited:false,exitPromise,lastReadAt:this.now()};this.sessions.set(key,session);
|
|
139
|
+
/*
|
|
140
|
+
* AND IT SAYS WHAT HAPPENED TO THE LAST ONE. Without this line, reopening a
|
|
141
|
+
terminal the app tidied away looks exactly like the app losing her
|
|
142
|
+
scrollback, which is the same event read as a bug. Dim, one line, above a
|
|
143
|
+
real prompt. */
|
|
144
|
+
const why=this.reaped.get(key);
|
|
145
|
+
if(why!==undefined){this.reaped.delete(key);session.buffer=`\x1b[2m[${why} This is a new one.]\x1b[0m\r\n`;}
|
|
146
|
+
if(place?.notice)session.buffer+=`\x1b[2m[${place.notice}]\x1b[0m\r\n`;
|
|
147
|
+
pty.onData(data=>{session.buffer+=data;const trim=Math.max(0,session.buffer.length-LIMIT);session.buffer=session.buffer.slice(trim);session.offset+=trim;});
|
|
148
|
+
pty.onExit(({exitCode})=>{session.exited=true;session.exitCode=exitCode;resolveExit();});
|
|
149
|
+
return this.read(key,0);
|
|
150
|
+
}
|
|
151
|
+
get(key){const s=this.sessions.get(key);if(!s)throw Error('Terminal session ended. Open a new terminal to continue.');return s;}
|
|
152
|
+
// EVERY READ IS THE PANEL SAYING IT IS STILL THERE. This one line is the whole
|
|
153
|
+
// attention signal the sweep runs on; see the note on the class.
|
|
154
|
+
read(key,offset=0){const s=this.get(key);if(!Number.isSafeInteger(offset)||offset<0)throw Error('Invalid terminal cursor.');s.lastReadAt=this.now();const end=s.offset+s.buffer.length;return {token:s.token,cwd:s.cwd,data:s.buffer.slice(Math.max(0,offset-s.offset)),offset:end,truncated:offset<s.offset,exited:s.exited,exitCode:s.exitCode,process:s.exited?'Exited':s.pty.process||'Shell'};}
|
|
155
|
+
write(key,data){const s=this.get(key);if(s.exited)throw Error('This shell has exited. Start a new session.');if(typeof data!=='string'||data.length>65536)throw Error('Terminal input is too large. Paste at most 64 KB at a time.');s.pty.write(data);}
|
|
156
|
+
resize(key,cols,rows){if(!Number.isInteger(cols)||!Number.isInteger(rows)||cols<2||cols>500||rows<2||rows>300)throw Error('Invalid terminal size.');const s=this.get(key);if(!s.exited)s.pty.resize(cols,rows);}
|
|
157
|
+
close(key){const s=this.sessions.get(key);if(s){if(!s.exited)this.disposeProcess(s.pty);this.sessions.delete(key);}}
|
|
158
|
+
async shutdown(){
|
|
159
|
+
const exits=[...this.sessions.values()].map(s=>s.exitPromise);
|
|
160
|
+
this.dispose();
|
|
161
|
+
let timer;
|
|
162
|
+
await Promise.race([Promise.all(exits),new Promise(resolve=>{timer=setTimeout(resolve,2000);})]);
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
await new Promise(resolve=>setImmediate(resolve));
|
|
165
|
+
}
|
|
166
|
+
// The sweep goes with the shells. A disposed manager that keeps a timer is a
|
|
167
|
+
// timer calling `close` on a Map somebody else now owns.
|
|
168
|
+
dispose(){if(this.timer){clearInterval(this.timer);this.timer=null;}for(const key of [...this.sessions.keys()])this.close(key);}
|
|
169
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const documentUrl=value=>{try{const u=new URL(value);u.search='';u.hash='';return u.href;}catch{return null;}};
|
|
2
|
+
export function terminalSenderAllowed(event,contents,allowedUrls=[new URL('../renderer/dist/index.html',import.meta.url).href,process.env.ZERO_DEV_URL].filter(Boolean)){
|
|
3
|
+
if(!contents||event?.sender!==contents||!event.senderFrame||event.senderFrame!==contents.mainFrame||event.senderFrame.parent)return false;
|
|
4
|
+
const url=documentUrl(event.senderFrame.url);
|
|
5
|
+
return !!url&&allowedUrls.some(allowed=>documentUrl(allowed)===url);
|
|
6
|
+
}
|