agentbox-app 0.1.12 → 0.1.13

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.
Files changed (214) hide show
  1. package/main/acp.mjs +296 -0
  2. package/main/agent-homes.mjs +158 -0
  3. package/main/agent-sessions.mjs +57 -20
  4. package/main/agent-updates.mjs +9 -22
  5. package/main/analytics.mjs +30 -0
  6. package/main/claude-bin.mjs +45 -311
  7. package/main/claude-input.mjs +9 -3
  8. package/main/claude-launch-env.mjs +54 -0
  9. package/main/claude-models.mjs +39 -7
  10. package/main/claude-plan.mjs +16 -16
  11. package/main/claude-usage.mjs +82 -25
  12. package/main/codex-bin.mjs +51 -249
  13. package/main/codex-session.mjs +38 -3
  14. package/main/codex-threads.mjs +41 -8
  15. package/main/codex-watch.mjs +67 -3
  16. package/main/config.mjs +26 -4
  17. package/main/corner-tag.mjs +489 -0
  18. package/main/crash-report.mjs +26 -3
  19. package/main/engine-bin.mjs +595 -0
  20. package/main/engine-setup.mjs +4 -15
  21. package/main/feedback.mjs +11 -6
  22. package/main/harnesses/claude-stream.mjs +208 -0
  23. package/main/harnesses/claude.mjs +84 -0
  24. package/main/harnesses/codex.mjs +65 -0
  25. package/main/harnesses/common.mjs +78 -0
  26. package/main/harnesses/instructions-file.mjs +116 -0
  27. package/main/harnesses/opencode.mjs +234 -0
  28. package/main/harnesses.mjs +65 -0
  29. package/main/importable-threads.mjs +38 -0
  30. package/main/ipc.mjs +165 -31
  31. package/main/local-app-frames.mjs +82 -0
  32. package/main/main.mjs +100 -4
  33. package/main/memory-gate-history.mjs +51 -12
  34. package/main/memory-gate-server.mjs +67 -11
  35. package/main/memory-gate.mjs +49 -8
  36. package/main/memory-log.mjs +124 -0
  37. package/main/notify.mjs +47 -4
  38. package/main/opencode-server.mjs +271 -0
  39. package/main/opencode.mjs +287 -0
  40. package/main/phone-link.mjs +551 -0
  41. package/main/relay-address.mjs +28 -0
  42. package/main/relay-client.mjs +246 -0
  43. package/main/row-label.mjs +6 -6
  44. package/main/runs-on.mjs +32 -35
  45. package/main/serve.mjs +113 -9
  46. package/main/settings.mjs +148 -10
  47. package/main/shell-path.mjs +86 -0
  48. package/main/ship-queue.mjs +24 -6
  49. package/main/sign-in-files.mjs +3 -7
  50. package/main/source-updater.mjs +27 -1
  51. package/main/start-hold.mjs +73 -0
  52. package/main/store.mjs +1 -1
  53. package/main/supervisor.mjs +832 -428
  54. package/main/task-commands.mjs +13 -17
  55. package/main/task-folders-offthread.mjs +6 -1
  56. package/main/task-folders.mjs +490 -98
  57. package/main/task-terminals.mjs +37 -7
  58. package/main/updater.mjs +71 -9
  59. package/main/worktree-include.mjs +494 -0
  60. package/package.json +10 -1
  61. package/renderer/dist/assets/abnfDiagram-YUKMZFIV-DPPuMWt-.js +1 -0
  62. package/renderer/dist/assets/arc-HmFfdseH.js +1 -0
  63. package/renderer/dist/assets/architectureDiagram-47P4ROYG-DxJ3zHrH.js +36 -0
  64. package/renderer/dist/assets/blockDiagram-E7TT5TSB-CbTXzOqQ.js +140 -0
  65. package/renderer/dist/assets/c4Diagram-CRA5TL53-X5ItFyLn.js +38 -0
  66. package/renderer/dist/assets/channel-BlT2g9Sz.js +1 -0
  67. package/renderer/dist/assets/chunk-2Q5K7J3B-DC12Ho_L.js +1 -0
  68. package/renderer/dist/assets/chunk-5VM5RSS4-ClVtfPd0.js +15 -0
  69. package/renderer/dist/assets/chunk-7TKQ45FW-DgcOm0HT.js +1 -0
  70. package/renderer/dist/assets/chunk-AW2ZBBNX-C01s4yoF.js +88 -0
  71. package/renderer/dist/assets/chunk-FVRAUYC3-Bj0zxhR4.js +2 -0
  72. package/renderer/dist/assets/chunk-HTAEGDNF-DWDeEhLU.js +1 -0
  73. package/renderer/dist/assets/chunk-JWPE2WC7-Dsqwv3eW.js +1 -0
  74. package/renderer/dist/assets/chunk-KQW6MTUR-CE86ANL2.js +1 -0
  75. package/renderer/dist/assets/chunk-XXDRQBXY-CoNTASfs.js +1 -0
  76. package/renderer/dist/assets/classDiagram-v2-K4WV4PDN-2wprLqtw.js +217 -0
  77. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +1 -0
  78. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +1 -0
  79. package/renderer/dist/assets/cose-bilkent-JH36ORCC-enqMiYbn.js +1 -0
  80. package/renderer/dist/assets/cynefin-EF2NZ3EQ-DHngLxvQ.js +141 -0
  81. package/renderer/dist/assets/cynefinDiagram-3GCD6N5R-C0rZZIl3.js +62 -0
  82. package/renderer/dist/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  83. package/renderer/dist/assets/dagre-W4DXFKR2-DQnaEWO7.js +4 -0
  84. package/renderer/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  85. package/renderer/dist/assets/diagram-2UJZ2QOL-CS8AlGPx.js +200 -0
  86. package/renderer/dist/assets/diagram-OVF4WLC6-yiDMHIkY.js +24 -0
  87. package/renderer/dist/assets/diagram-PFPMY2P6-XbQKzbBA.js +30 -0
  88. package/renderer/dist/assets/diagram-UMYRVEAY-dhVvlfl_.js +24 -0
  89. package/renderer/dist/assets/diagram-YEKJPTXX-DZHe8SCf.js +41 -0
  90. package/renderer/dist/assets/diagram-ZIFT7M5P-B5h6aXVR.js +3 -0
  91. package/renderer/dist/assets/ebnfDiagram-VR2GEFS7-PR9ScDco.js +1 -0
  92. package/renderer/dist/assets/elk-IJKZMXRS-Cg-yQXPd.js +27 -0
  93. package/renderer/dist/assets/erDiagram-O2IAPWRE-omOrJMxu.js +99 -0
  94. package/renderer/dist/assets/flowDiagram-OXPTDLAJ-aOX2hzvT.js +213 -0
  95. package/renderer/dist/assets/ganttDiagram-R7TSEDQI-B1H3pWyI.js +292 -0
  96. package/renderer/dist/assets/gitGraphDiagram-XJZIOB7I-Cvw304HC.js +106 -0
  97. package/renderer/dist/assets/graph-DGbsTEsI.js +1 -0
  98. package/renderer/dist/assets/infoDiagram-5W2HQ5XZ-_ZXcNViH.js +2 -0
  99. package/renderer/dist/assets/init-Gi6I4Gst.js +1 -0
  100. package/renderer/dist/assets/ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js +70 -0
  101. package/renderer/dist/assets/journeyDiagram-COXZFDF6-fr62QoAt.js +139 -0
  102. package/renderer/dist/assets/kanban-definition-P3RFRI5V-J8zsabCe.js +89 -0
  103. package/renderer/dist/assets/katex-C5jXJg4s.js +257 -0
  104. package/renderer/dist/assets/linear-BmNdEtIi.js +1 -0
  105. package/renderer/dist/assets/main-ChNdnUvt.js +1132 -0
  106. package/renderer/dist/assets/main-nj0QR5kg.css +32 -0
  107. package/renderer/dist/assets/mermaid.core-CnYzvFkz.js +314 -0
  108. package/renderer/dist/assets/mindmap-definition-LZFPQGKD-Bfcxc7PM.js +96 -0
  109. package/renderer/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  110. package/renderer/dist/assets/pegDiagram-BGZESJAR-CwUJOq6X.js +1 -0
  111. package/renderer/dist/assets/pieDiagram-CAPJLFHJ-BrwMmqfC.js +39 -0
  112. package/renderer/dist/assets/product-name-CTKLYNWY.js +40 -0
  113. package/renderer/dist/assets/quadrantDiagram-GDMTTRAM-BN4Stoj-.js +7 -0
  114. package/renderer/dist/assets/railroadDiagram-SM67HX2B-CPZ5bbeR.js +1 -0
  115. package/renderer/dist/assets/requirementDiagram-X7JNWC4A-CgRnimVe.js +84 -0
  116. package/renderer/dist/assets/sankeyDiagram-UM26HJRW-Bt2rej__.js +40 -0
  117. package/renderer/dist/assets/sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js +169 -0
  118. package/renderer/dist/assets/sizeCapture-INFHLROL-usyNyXyh.js +1 -0
  119. package/renderer/dist/assets/stateDiagram-v2-TBUQTH76-CRzUBb0u.js +291 -0
  120. package/renderer/dist/assets/swimlanes-N4OXWK64-C1GJvMS0.js +1 -0
  121. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +8 -0
  122. package/renderer/dist/assets/timeline-definition-YOQAKGHF-caeyPlcc.js +120 -0
  123. package/renderer/dist/assets/usecaseDiagram-VIAY4XPW-BN_SV6PC.js +370 -0
  124. package/renderer/dist/assets/vennDiagram-BLWOH2XV-BLGuaUX-.js +34 -0
  125. package/renderer/dist/assets/wardleyDiagram-YMQ3BMBF-Da0cJeNc.js +78 -0
  126. package/renderer/dist/assets/xychartDiagram-TAQBALBS-cGfUffMk.js +7 -0
  127. package/renderer/dist/corner-tag.html +13 -0
  128. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  129. package/renderer/dist/emulator/data/compression/README.md +11 -0
  130. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  131. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  132. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  133. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  134. package/renderer/dist/emulator/data/cores/README.md +22 -0
  135. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  136. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  137. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  138. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  139. package/renderer/dist/emulator/data/cores/package.json +65 -0
  140. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  141. package/renderer/dist/emulator/data/emulator.css +1614 -0
  142. package/renderer/dist/emulator/data/loader.js +170 -0
  143. package/renderer/dist/emulator/data/localization/README.md +63 -0
  144. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  145. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  146. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  147. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  148. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  149. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  150. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  151. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  152. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  153. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  154. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  155. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  156. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  157. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  158. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  159. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  160. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  161. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  162. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  163. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  164. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  165. package/renderer/dist/emulator/data/src/compression.js +142 -0
  166. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  167. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  168. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  169. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  170. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  171. package/renderer/dist/emulator/data/src/storage.js +125 -0
  172. package/renderer/dist/emulator/data/version.json +5 -0
  173. package/renderer/dist/emulator/host.js +297 -0
  174. package/renderer/dist/emulator/index.html +28 -0
  175. package/renderer/dist/emulator/provenance.json +6 -0
  176. package/renderer/dist/index.html +16 -3
  177. package/scripts/approval-server.sh +24 -5
  178. package/scripts/before-publish.mjs +12 -10
  179. package/shared/agent-import.mjs +2 -0
  180. package/shared/agents.d.mts +1 -0
  181. package/shared/bridge-map.mjs +18 -0
  182. package/shared/claude-commands.generated.mjs +6 -4
  183. package/shared/claude-models.generated.mjs +6 -4
  184. package/shared/corner-tag.d.mts +14 -0
  185. package/shared/corner-tag.mjs +141 -0
  186. package/shared/engines.mjs +20 -5
  187. package/shared/harness-definitions.mjs +103 -0
  188. package/shared/notify-rules.d.mts +25 -0
  189. package/shared/notify-rules.mjs +19 -0
  190. package/shared/provider-commands.mjs +10 -2
  191. package/shared/rank.d.mts +2 -0
  192. package/shared/rank.mjs +50 -0
  193. package/shared/relay-address.mjs +6 -0
  194. package/shared/relay-crypto.mjs +165 -0
  195. package/shared/runs-on.d.mts +0 -10
  196. package/shared/runs-on.mjs +25 -49
  197. package/shared/shortcuts.mjs +10 -4
  198. package/shared/spawn-trouble.mjs +57 -6
  199. package/shared/terminal-input.d.mts +1 -0
  200. package/shared/terminal-input.mjs +30 -0
  201. package/shared/terminal-keys.d.mts +1 -0
  202. package/shared/terminal-keys.mjs +16 -0
  203. package/shared/terminal-size.d.mts +4 -0
  204. package/shared/terminal-size.mjs +29 -0
  205. package/shared/usage.d.mts +3 -0
  206. package/shared/usage.mjs +7 -0
  207. package/shared/where-it-runs-from.d.mts +43 -0
  208. package/shared/where-it-runs-from.mjs +155 -0
  209. package/shared/work-items.mjs +25 -1
  210. package/shared/work-lines.mjs +1 -1
  211. package/shared/xterm-invalid-range.d.mts +2 -0
  212. package/shared/xterm-invalid-range.mjs +58 -0
  213. package/renderer/dist/assets/index-ChKsQkRw.js +0 -1169
  214. package/renderer/dist/assets/index-ClBXnq3B.css +0 -32
@@ -39,10 +39,29 @@ case "$here" in
39
39
  exec env ELECTRON_RUN_AS_NODE=1 "$app/MacOS/$exe" "$res/app.asar/main/approval-prompt-server.mjs"
40
40
  ;;
41
41
  *)
42
- # Running from source: node is nvm-only on this machine, and MCP servers
43
- # boot from a bare login shell, so resolve it the same way the store
44
- # launcher does.
45
- export PATH="$HOME/.nvm/versions/node/$(ls "$HOME/.nvm/versions/node" 2>/dev/null | tail -1)/bin:$PATH"
46
- exec node "$(cd "$here/.." && pwd)/main/approval-prompt-server.mjs"
42
+ # RUNNING FROM SOURCE, AND THIS LINE USED TO ASSUME NVM. It prepended
43
+ # $HOME/.nvm/versions/node/<newest>/bin and exec'd node. On a Mac that
44
+ # installed node by Homebrew, Volta, asdf or not at all, that folder is
45
+ # absent, PATH is untouched, and an MCP server booting from a bare login
46
+ # shell has no node on it: the exec failed and every agent died the same
47
+ # four-second death described at the top of this file. Reported from
48
+ # source on 2026-10-07 (issue 21).
49
+ #
50
+ # So the app hands its own binary down in ZERO_APPROVALS_RUNTIME, and
51
+ # ELECTRON_RUN_AS_NODE=1 makes that binary a node, exactly as the packaged
52
+ # branch above does. Nothing on the machine is needed.
53
+ server="$(cd "$here/.." && pwd)/main/approval-prompt-server.mjs"
54
+ if [ -f "$ZERO_APPROVALS_RUNTIME" ] && [ -x "$ZERO_APPROVALS_RUNTIME" ]; then
55
+ exec env ELECTRON_RUN_AS_NODE=1 "$ZERO_APPROVALS_RUNTIME" "$server"
56
+ fi
57
+ # Nobody handed one down, so this is somebody running the script by hand.
58
+ # A node already on PATH wins; nvm's newest is the last resort, for the
59
+ # bare login shell that has never sourced nvm.
60
+ if ! command -v node >/dev/null 2>&1; then
61
+ nvm="$HOME/.nvm/versions/node"
62
+ newest="$(ls "$nvm" 2>/dev/null | tail -1)"
63
+ [ -n "$newest" ] && export PATH="$nvm/$newest/bin:$PATH"
64
+ fi
65
+ exec node "$server"
47
66
  ;;
48
67
  esac
@@ -69,17 +69,19 @@ function wouldShip(changed) {
69
69
  // If npm cannot tell us, the honest answer is the cautious one.
70
70
  return changed;
71
71
  }
72
- // THE FILES THE PUBLISH ITSELF REWRITES, which would otherwise make every
73
- // attempt poison the next one. `prepack` runs `npm run build`, and the build
74
- // reads the installed Claude Code and writes what it found into
75
- // shared/*.generated.*. So a failed publish leaves those modified, and the
76
- // next publish refuses because of dirt the last publish made. Measured
77
- // 2026-09-28: one failed attempt left claude-models.generated.mjs changed by
78
- // a version number, and that alone blocked the retry.
72
+ // THE FILES A PUBLISH USED TO REWRITE, which made every attempt poison the
73
+ // next one. `prepack` runs `npm run build`, and the build read the installed
74
+ // Claude Code and wrote what it found over shared/*.generated.*. So a failed
75
+ // publish left those modified and the next publish refused because of dirt the
76
+ // last publish had made. Measured 2026-09-28: one failed attempt left
77
+ // claude-models.generated.mjs changed by a version number, and that alone
78
+ // blocked the retry.
79
79
  //
80
- // They are safe to ignore here BECAUSE the build rewrites them anyway. What
81
- // ships is whatever the build produces on the machine publishing, every time,
82
- // committed or not, so a difference in the working tree changes nothing.
80
+ // THE BUILD NO LONGER WRITES THEM (w-6bbb709b1f): it writes
81
+ // shared/*.local.json, which is gitignored, and `npm run read:claude` is what
82
+ // updates the committed tables. This filter stays as the belt for a folder an
83
+ // OLDER build already dirtied, and because what ships is whatever the build
84
+ // produces on the machine publishing, so a difference here changes nothing.
83
85
  return changed.filter((f) => packed.has(f) && !/\.generated\./.test(f));
84
86
  }
85
87
 
@@ -148,6 +148,8 @@ export function whenWords(when, now = Date.now()) {
148
148
  /** Where she was when she started it, in the two places she named. */
149
149
  export function startedIn(source) {
150
150
  if (source === 'codex') return 'in Codex';
151
+ // VS Code, Cursor or Windsurf: the extension says which family, not which one.
152
+ if (source === 'editor') return 'in your code editor';
151
153
  return source === 'desktop' ? 'in the Claude Code desktop app' : 'in a terminal';
152
154
  }
153
155
 
@@ -110,6 +110,7 @@ export interface AgentTurnLike {
110
110
  // ONE THING THE AGENT RAN, between two messages. Shape B.
111
111
  export interface AgentWorkLike {
112
112
  kind: 'work';
113
+ standalone?: true;
113
114
  at: number;
114
115
  verb: string;
115
116
  subject: string;
@@ -15,6 +15,10 @@ export const REQUEST_CHANNELS = {
15
15
  terminal: 'zero:terminal',
16
16
  agentUpdate: 'zero:agent-update',
17
17
  snapshot: 'zero:snapshot',
18
+ refreshUsage: 'zero:refresh-usage',
19
+ // Whether a screen reader is running, which is the terminal pane's question
20
+ // before it builds xterm's accessibility tree.
21
+ assistiveTech: 'zero:assistiveTech',
18
22
  // The next page of old finished threads, at the foot of Done and All.
19
23
  olderItems: 'zero:older-items',
20
24
  dashboard: 'zero:dashboard',
@@ -73,9 +77,11 @@ export const REQUEST_CHANNELS = {
73
77
  agentThreads: 'zero:agent-threads',
74
78
  importThreads: 'zero:import-threads',
75
79
  badge: 'zero:badge',
80
+ cornerTag: 'zero:corner-tag',
76
81
  bootInfo: 'zero:boot-info',
77
82
  updateCheck: 'zero:update-check',
78
83
  updateInstall: 'zero:update-install',
84
+ updateMove: 'zero:update-move',
79
85
  notify: 'zero:notify',
80
86
  stopSession: 'zero:stop-session',
81
87
  runNow: 'zero:run-now',
@@ -91,6 +97,10 @@ export const REQUEST_CHANNELS = {
91
97
  settingsRead: 'zero:settings-read',
92
98
  claudeRecheck: 'zero:claude-recheck',
93
99
  codexRecheck: 'zero:codex-recheck',
100
+ // Point at the coding agent yourself, or hand the choice back to the search.
101
+ // The browser tab has no file panel, so main answers it ok-with-nothing-done
102
+ // there (main/serve.mjs stubs showOpenDialog) and the row stays honest.
103
+ engineBin: 'zero:engine-bin',
94
104
  engineSetup: 'zero:engine-setup',
95
105
  codexAddAccount: 'zero:codex-add-account',
96
106
  claudeAddAccount: 'zero:claude-add-account',
@@ -128,8 +138,13 @@ export const REQUEST_CHANNELS = {
128
138
  resumeItems: 'zero:resume-items',
129
139
  redeliver: 'zero:redeliver',
130
140
  setProductOrder: 'zero:set-product-order',
141
+ setThreadPlaces: 'zero:set-thread-places',
142
+ setThreadPins: 'zero:set-thread-pins',
131
143
  setProductHidden: 'zero:set-product-hidden',
132
144
  approve: 'zero:approve',
145
+ // The phone door's Settings page (main/phone-link.mjs): its state, on, off,
146
+ // a new key, and which address the QR code carries. The desktop window only.
147
+ phoneLink: 'zero:phone-link',
133
148
  };
134
149
 
135
150
  /** Told rather than asked: the app talks and the screen listens. */
@@ -140,9 +155,12 @@ export const PUSH_CHANNELS = {
140
155
  onZoomPercent: 'zero:zoom-percent',
141
156
  onRecovered: 'zero:recovered',
142
157
  onOpenItem: 'zero:open-item',
158
+ onOpenSettings: 'zero:open-settings',
143
159
  onScreenDetail: 'zero:screen-detail',
144
160
  onEscapeBrowser: 'zero:escape-browser',
145
161
  onKeyInTheFile: 'zero:key-in-the-file',
162
+ // A phone connected, left, or the key changed.
163
+ onPhoneChanged: 'zero:phone-changed',
146
164
  };
147
165
 
148
166
  // THE FEW THAT ARE NOT A STRAIGHT HANDOVER. Each is called with bare values
@@ -1,10 +1,12 @@
1
1
  // GENERATED BY scripts/read-claude-commands.mjs. DO NOT EDIT BY HAND.
2
2
  //
3
3
  // Claude Code's own words for the commands Agentbox's reply box offers,
4
- // read out of the copy of Claude Code installed on the machine that built
5
- // this. `npm run build` rewrites it. Committed so a machine with no Claude
6
- // Code on it can still build, and so a version that renames one of the eight
7
- // shows up as a diff rather than as a wrong menu.
4
+ // read out of the copy of Claude Code installed on a machine that ran
5
+ // `npm run read:claude`. A BUILD DOES NOT REWRITE THIS: a build wrote what it
6
+ // read over this tracked file, and every folder that had run the app then
7
+ // looked like it held work. Committed so a machine with no Claude Code on it
8
+ // can still build, and so a version that renames one of the eight shows up as
9
+ // a diff rather than as a wrong menu.
8
10
  //
9
11
  // WHY IT IS READ AND NOT TYPED: reading them out of the installed binary at
10
12
  // build time, rather than typing them into a file, is the only way the list
@@ -1,10 +1,12 @@
1
1
  // GENERATED BY scripts/read-claude-models.mjs. DO NOT EDIT BY HAND.
2
2
  //
3
3
  // Which model each alias really is, and Claude Code's own name for it, read
4
- // out of the copy of Claude Code installed on the machine that built this.
5
- // `npm run build` rewrites it. Committed so a machine with no Claude Code on
6
- // it can still build, and so a version that points an alias at a different
7
- // model shows up as a diff rather than as a wrong label on the picker.
4
+ // out of the copy of Claude Code installed on a machine that ran
5
+ // `npm run read:claude`. A BUILD DOES NOT REWRITE THIS: a build wrote what it
6
+ // read over this tracked file, and every folder that had run the app then
7
+ // looked like it held work. Committed so a machine with no Claude Code on it
8
+ // can still build, and so a version that points an alias at a different model
9
+ // shows up as a diff rather than as a wrong label on the picker.
8
10
  //
9
11
  // WHY IT IS READ AND NOT TYPED: labelling a model wrong is expensive, because
10
12
  // Agentbox sends the alias, the CLI decides what the alias means, and a version
@@ -0,0 +1,14 @@
1
+ // Types for shared/corner-tag.mjs, the corner tag's rules, which main (Node)
2
+ // and the tag's page (Vite) share one copy of. Only what the page calls is
3
+ // declared; the window placement is main's half.
4
+
5
+ export type HideChoice = { key: '5m' | '30m' | 'today'; label: string };
6
+ export const HIDE_CHOICES: HideChoice[];
7
+
8
+ export function tagSays(counts: { ready?: number; working?: number }): { kind: 'ready' | 'working'; text: string } | null;
9
+
10
+ export type ReadyLine = { id: string; title: string; says: string; since?: number; open?: string | null };
11
+ export function cardLines<T extends ReadyLine>(ready: T[], now: number, max?: number): {
12
+ lines: (T & { waited: string })[];
13
+ more: number;
14
+ };
@@ -0,0 +1,141 @@
1
+ // THE CORNER TAG (w-dafae58a23): the rules, and nothing that touches a window.
2
+ //
3
+ // You lose track of agents once you are in another app, and the ones waiting on
4
+ // you sit idle. The answer that survived eight rounds of drawings is a small tag
5
+ // in a corner of the screen that says "2 ready for you" when an agent has
6
+ // something for you and "20 working" when none does. Twenty agents running is
7
+ // ordinary, so working agents are a count and never a list: only what is ready
8
+ // for you is worth a line.
9
+ //
10
+ // It must never become a nag. So it hides for a while and comes back on its
11
+ // own, there is no one-click way to make it gone for good (that becomes a thing
12
+ // you closed once and forgot existed), it is turned off only in Settings, and
13
+ // it steps aside while you are in the app itself.
14
+ //
15
+ // main/corner-tag.mjs owns the window and renderer/src/corner-tag owns the
16
+ // page; both decide through this file so the tests pin behaviour, not wiring.
17
+
18
+ /** Space between the tag and the edge of the screen when it rests in a corner. */
19
+ export const MARGIN = 18;
20
+
21
+ /** The ways to hide it. All of them come back; none of them is permanent. */
22
+ export const HIDE_CHOICES = [
23
+ { key: '5m', label: '5 min' },
24
+ { key: '30m', label: '30 min' },
25
+ { key: 'today', label: 'Today' },
26
+ ];
27
+
28
+ /** When a hide chosen at `now` runs out. 0 means "not hidden". */
29
+ export function hiddenUntil(choice, now) {
30
+ if (choice === '5m') return now + 5 * 60_000;
31
+ if (choice === '30m') return now + 30 * 60_000;
32
+ if (choice === 'today') {
33
+ const d = new Date(now);
34
+ d.setHours(24, 0, 0, 0);
35
+ return d.getTime();
36
+ }
37
+ return 0;
38
+ }
39
+
40
+ /** The words on the tag, or null when there is nothing to say. Short, because
41
+ * "22 ready for you" in the corner of a full screen was more than it needed;
42
+ * "waiting" was picked over "ready" and "need you". */
43
+ export function tagSays({ ready = 0, working = 0 } = {}) {
44
+ if (ready > 0) return { kind: 'ready', text: `${ready} waiting` };
45
+ if (working > 0) return { kind: 'working', text: `${working} working` };
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * What the tag counts and lists: everything waiting on you, newest first.
51
+ * The first version put threads from weeks ago at the top of the list; the
52
+ * second cut everything older than a day, and then a Needs you of 58 read
53
+ * "3 waiting" in the corner (w-0961e5523b). So the count is the whole of it,
54
+ * the same number the app shows, and only the order keeps the old ones out
55
+ * of the way: the list shows the newest three and folds the rest into "and N
56
+ * more". Something with no moment it became ready (an agent stopped on a
57
+ * yes, right now) sorts as now.
58
+ */
59
+ export function readyNow(ready, now) {
60
+ return [...(ready ?? [])]
61
+ .sort((a, b) => (Number.isFinite(b.since) ? b.since : now) - (Number.isFinite(a.since) ? a.since : now));
62
+ }
63
+
64
+ /** Whether the tag is on screen right now. */
65
+ export function tagShows({ on, hiddenUntil: until = 0, now, inApp, ready = 0, working = 0 }) {
66
+ if (on === false) return false;
67
+ if (until > now) return false;
68
+ // In the app itself the inbox already says all of this, bigger.
69
+ if (inApp) return false;
70
+ return !!tagSays({ ready, working });
71
+ }
72
+
73
+ /** How long something has waited, in units. */
74
+ export function waitedFor(since, now) {
75
+ if (!Number.isFinite(since)) return '';
76
+ const min = Math.floor((now - since) / 60_000);
77
+ if (min < 1) return 'now';
78
+ if (min < 60) return `${min} min`;
79
+ return `${Math.floor(min / 60)} h`;
80
+ }
81
+
82
+ /** The lines under the tag: the newest first, three at most. */
83
+ export function cardLines(ready, now, max = 3) {
84
+ const sorted = readyNow(ready, now, Infinity);
85
+ const lines = sorted.slice(0, max).map((r) => ({ ...r, waited: waitedFor(r.since, now) }));
86
+ return { lines, more: Math.max(0, sorted.length - lines.length) };
87
+ }
88
+
89
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
90
+
91
+ function areaFor(point, areas) {
92
+ return areas.find((a) => point.x >= a.x && point.x < a.x + a.width && point.y >= a.y && point.y < a.y + a.height) ?? areas[0];
93
+ }
94
+
95
+ /**
96
+ * Where the tag goes when it appears: back where you last dragged it, pulled
97
+ * onto a screen if that screen is gone, or the bottom-right corner the first
98
+ * time. `spot` is the tag's top-left, `areas` are the screens' work areas.
99
+ */
100
+ export function restingSpot(spot, size, areas) {
101
+ if (!spot) {
102
+ const a = areas[0];
103
+ return { x: a.x + a.width - MARGIN - size.width, y: a.y + a.height - MARGIN - size.height };
104
+ }
105
+ const a = areaFor({ x: spot.x + size.width / 2, y: spot.y + size.height / 2 }, areas) ?? areas[0];
106
+ return {
107
+ x: clamp(spot.x, a.x, a.x + a.width - size.width),
108
+ y: clamp(spot.y, a.y, a.y + a.height - size.height),
109
+ };
110
+ }
111
+
112
+ function inRightHalf(r, a) { return r.x + r.width / 2 > a.x + a.width / 2; }
113
+ function inLowerHalf(r, a) { return r.y + r.height / 2 > a.y + a.height / 2; }
114
+
115
+ /**
116
+ * The tag's new bounds when its words change length. The edge nearest its
117
+ * corner stays put, so "2 ready for you" turning into "20 working" does not
118
+ * walk the tag away from the edge you parked it against.
119
+ */
120
+ export function keepCorner(bounds, size, area) {
121
+ const x = inRightHalf(bounds, area) ? bounds.x + bounds.width - size.width : bounds.x;
122
+ const y = inLowerHalf(bounds, area) ? bounds.y + bounds.height - size.height : bounds.y;
123
+ return { x, y, width: size.width, height: size.height };
124
+ }
125
+
126
+ /**
127
+ * Where the list opens when you point at the tag: toward the middle of the
128
+ * screen, so it never runs off an edge, lined up with the tag's outer edge.
129
+ */
130
+ export function cardPlacement(tag, card, area, gap = 8) {
131
+ let above = inLowerHalf(tag, area);
132
+ const alignRight = inRightHalf(tag, area);
133
+ const x = clamp(alignRight ? tag.x + tag.width - card.width : tag.x, area.x, area.x + area.width - card.width);
134
+ const up = tag.y - gap - card.height;
135
+ const down = tag.y + tag.height + gap;
136
+ // A tall list near a short screen's middle may not fit the side it prefers.
137
+ if (above && up < area.y) above = false;
138
+ else if (!above && down + card.height > area.y + area.height) above = true;
139
+ const y = clamp(above ? up : down, area.y, area.y + area.height - card.height);
140
+ return { above, alignRight, card: { x, y, width: card.width, height: card.height } };
141
+ }
@@ -1,3 +1,4 @@
1
+ import { HARNESS_DEFINITIONS } from './harness-definitions.mjs';
1
2
  // WHICH CODING AGENT A TASK IS HANDED TO.
2
3
  //
3
4
  // AND THE PREMISE SHE GAVE FOR IT DID NOT HOLD. Measured head to head on her
@@ -44,11 +45,25 @@
44
45
  // main/codex.mjs when that comes back, because the renderer must never import
45
46
  // spawn code.
46
47
 
47
- /** The engines, in the order the picker draws them. */
48
- export const ENGINES = [
49
- { id: 'claude', label: 'Claude Code', word: 'Claude Code' },
50
- { id: 'codex', label: 'Codex', word: 'Codex' },
51
- ];
48
+ /**
49
+ * The engines, in the order the picker draws them.
50
+ *
51
+ * ADMITTED ONES ONLY, and that word is doing real work as of 2026-10-07. A
52
+ * harness being integrated and a harness being offered are two different
53
+ * decisions, and this list is the second one. OpenCode is registered in
54
+ * main/harnesses.mjs, its transport is measured and tested, and it is
55
+ * deliberately absent from here -- so `ENGINE_IDS`, `isEngine`, `engineFor`,
56
+ * the composer's picker, the Settings row and the byline all behave as if it
57
+ * were not there, while `harnessFor('opencode')` works for anything that asks
58
+ * for it by name. Without this filter, adding the definition alone would have
59
+ * put a third word in front of every user the moment it landed.
60
+ *
61
+ * docs/harnesses.md holds the bar a harness clears before `admitted` goes
62
+ * true, and the product review that turning it on requires.
63
+ */
64
+ export const ENGINES = HARNESS_DEFINITIONS
65
+ .filter(({ admitted }) => admitted !== false)
66
+ .map(({ id, label, word }) => ({ id, label, word }));
52
67
 
53
68
  export const ENGINE_IDS = ENGINES.map((e) => e.id);
54
69
 
@@ -0,0 +1,103 @@
1
+ // Names, flags, paths, and capabilities are data. This file is safe for the
2
+ // renderer: it imports no processes, credentials, filesystem, or transports.
3
+ const freeze = value => {
4
+ if (value && typeof value === 'object') { for (const child of Object.values(value)) freeze(child); Object.freeze(value); }
5
+ return value;
6
+ };
7
+ export const HARNESS_DEFINITIONS = freeze([
8
+ {
9
+ id: 'claude', label: 'Claude Code', word: 'Claude Code', binKey: 'claudeBin',
10
+ homeEnv: 'CLAUDE_CONFIG_DIR', accountPrefix: '', package: '@anthropic-ai/claude-code',
11
+ commandMenu: 'claude', commandRoute: 'claude',
12
+ casks: ['claude-code', 'claude-code@latest'],
13
+ installer: { shell: '/bin/bash', url: 'https://claude.ai/install.sh', pipe: 'bash' },
14
+ loginArgs: ['auth', 'login', '--claudeai'], statusArgs: ['auth', 'status', '--json'],
15
+ credentialFiles: ['.claude.json', '.credentials.json'],
16
+ // HOW A WORKER IS STARTED, AS DATA. This was six hardcoded flag names in
17
+ // main/supervisor.mjs, which made the cheapest possible new engine — one
18
+ // that speaks this same stream-json and differs by a binary and the
19
+ // `outputFormat` value, as Grok Build does in
20
+ // github.com/savannahfeder/agentbox/issues/21 — a supervisor edit. A key
21
+ // left out here means this engine does not take that flag, and the builder
22
+ // leaves it off the line rather than the call site asking who it is.
23
+ run: {
24
+ print: '-p',
25
+ outputFormat: { flag: '--output-format', value: 'stream-json' },
26
+ stream: ['--verbose', '--include-partial-messages'],
27
+ effort: '--effort',
28
+ system: '--append-system-prompt',
29
+ resume: '--resume',
30
+ fork: '--fork-session',
31
+ // Replies arrive on stdin, so the prompt leaves the line when they do.
32
+ input: ['--input-format', 'stream-json', '--replay-user-messages'],
33
+ },
34
+ scrubPatterns: ['^ANTHROPIC_', '^CLAUDE_CODE_', '^CLAUDECODE$', '^CLAUDE_PID$', '^CLAUDE_EFFORT$', '^CLAUDE_CONFIG_DIR$'],
35
+ capabilities: { nativeCommands: true, fork: true, nativeReview: false, remoteControl: true, inlineImages: false, claudeEffort: true },
36
+ imageInstruction: 'Open each one with the Read tool, at the absolute path below.',
37
+ },
38
+ {
39
+ id: 'codex', label: 'Codex', word: 'Codex', binKey: 'codexBin',
40
+ homeEnv: 'CODEX_HOME', accountPrefix: 'codex:', package: '@openai/codex', casks: ['codex'],
41
+ commandMenu: 'codex', commandRoute: 'codex',
42
+ installer: { shell: '/bin/sh', url: 'https://chatgpt.com/codex/install.sh', pipe: 'sh' },
43
+ loginArgs: ['login'], statusArgs: ['login', 'status'], credentialFiles: ['auth.json'],
44
+ // NO `run`, AND THAT IS THE POINT OF THE SHAPE. Codex is driven over its
45
+ // app-server RPC: the prompt is a turn's `input`, the system block a
46
+ // thread's `developerInstructions`, the resume a thread id. An engine with
47
+ // no command line is handed none rather than handed Claude Code's.
48
+ scrubPatterns: ['^ANTHROPIC_', '^CLAUDE_CODE_', '^CLAUDECODE$', '^CLAUDE_PID$', '^CLAUDE_EFFORT$', '^CLAUDE_CONFIG_DIR$', '^OPENAI_', '^CODEX_'],
49
+ capabilities: { nativeCommands: false, fork: false, nativeReview: true, remoteControl: false, inlineImages: true, claudeEffort: false },
50
+ imageInstruction: 'Each one is attached to this message as an image, and is also on disk at the absolute path below.',
51
+ },
52
+ {
53
+ // OPENCODE, AND IT IS NOT OFFERED TO ANYBODY YET -- see `admitted` below.
54
+ //
55
+ // THE ONE THING THAT MAKES IT DIFFERENT FROM THE OTHER TWO: it needs no
56
+ // sign-in at all. Measured on a real OpenCode 1.18.35 on 2026-10-07 with
57
+ // `auth.json` holding zero credentials, `opencode models` still listed ten
58
+ // models and a run on one of them finished and reported `"cost":0`. They
59
+ // are the OpenCode Zen free tier. So this is the first harness where the
60
+ // first run costs nothing and asks for nothing, which is also why
61
+ // `signIn` below is a real command rather than a requirement.
62
+ //
63
+ // ITS CREDENTIAL STORE IS ITS OWN AND AGENTBOX NEVER TOUCHES IT. Keys go
64
+ // into ~/.local/share/opencode/auth.json by `opencode auth login`, which
65
+ // is the same shape as `claude auth login` and `codex login` above. The
66
+ // scrub list is therefore WIDER than the other two: it takes out every
67
+ // provider key OpenCode could otherwise read out of our environment,
68
+ // because CLAUDE.md says no API key goes into a worker, and OpenCode is
69
+ // the one tool here that would happily use one if it found it.
70
+ id: 'opencode', label: 'OpenCode', word: 'OpenCode', binKey: 'opencodeBin',
71
+ homeEnv: 'OPENCODE_CONFIG', accountPrefix: 'opencode:', package: 'opencode-ai',
72
+ commandMenu: 'opencode', commandRoute: 'opencode',
73
+ casks: ['opencode', 'sst/tap/opencode'],
74
+ installer: { shell: '/bin/bash', url: 'https://opencode.ai/install', pipe: 'bash' },
75
+ loginArgs: ['auth', 'login'], statusArgs: ['auth', 'list'],
76
+ credentialFiles: ['auth.json'],
77
+ scrubPatterns: [
78
+ '^ANTHROPIC_', '^CLAUDE_CODE_', '^CLAUDECODE$', '^CLAUDE_PID$', '^CLAUDE_EFFORT$', '^CLAUDE_CONFIG_DIR$',
79
+ '^OPENAI_', '^CODEX_', '^OPENCODE_', '^OPENROUTER_', '^GEMINI_', '^GOOGLE_',
80
+ '^AWS_', '^AZURE_', '^GROQ_', '^MISTRAL_', '^DEEPSEEK_', '^XAI_', '^CEREBRAS_',
81
+ '^TOGETHER_', '^FIREWORKS_', '^DEEPINFRA_', '^BASETEN_', '^NVIDIA_', '^GITLAB_TOKEN$',
82
+ '_API_KEY$', '_ACCESS_TOKEN$',
83
+ ],
84
+ // NO USAGE AND NO PLAN. There is no endpoint on the server and no command
85
+ // on the CLI that reports what is left of anything, because OpenCode bills
86
+ // through whichever provider the user connected and does not know. Per-run
87
+ // tokens and cost DO arrive (`step-finish`), and the adapter reports those;
88
+ // a plan limit is declared missing rather than faked as zero.
89
+ capabilities: { nativeCommands: true, fork: true, nativeReview: false, remoteControl: false, inlineImages: false, claudeEffort: false, planLimits: false },
90
+ imageInstruction: 'Open each one with the read tool, at the absolute path below.',
91
+ // ADMISSION IS A SEPARATE DECISION FROM INTEGRATION, and it is not ours.
92
+ // docs/harnesses.md: "A provider is not offered until that path works", and
93
+ // "a new provider's networking or permission scope requires the
94
+ // corresponding product policy review before admission." The transport is
95
+ // verified (see main/opencode-server.mjs for what was measured and how);
96
+ // whether OpenCode appears in the picker is a product call, so this stays
97
+ // false until somebody turns it on deliberately.
98
+ admitted: false,
99
+ },
100
+ ]);
101
+ export function harnessDefinition(id = 'claude') {
102
+ return HARNESS_DEFINITIONS.find(h => h.id === id) ?? null;
103
+ }
@@ -0,0 +1,25 @@
1
+ // Types for shared/notify-rules.mjs, which is plain ESM so that main (Node)
2
+ // and the screen (Vite) can share one copy of the rule about when Agentbox is
3
+ // allowed to interrupt you. The screen asks only `isNew`, which is the
4
+ // question before "are you away"; the rest is main's.
5
+
6
+ export const AWAY_AFTER_MS: number;
7
+
8
+ export function atTheApp(state?: { focused?: boolean; idleMs?: number }): boolean;
9
+
10
+ // `known` is the ids on the snapshot before this one, and null means there has
11
+ // not been one. Empty and never-looked are different facts, and the module
12
+ // says what reading one as the other cost.
13
+ export function isNew(known: ReadonlySet<string> | null | undefined, id: string): boolean;
14
+
15
+ export function clip(text: unknown, max?: number): string;
16
+
17
+ export function shouldSpeak(state?: {
18
+ spoke?: boolean;
19
+ spokeAsk?: boolean;
20
+ waiting?: ReadonlyArray<{ kind?: string } | null | undefined>;
21
+ }): boolean;
22
+
23
+ export function banner(
24
+ waiting: ReadonlyArray<{ id?: string; title?: unknown; kind?: string } | null | undefined> | null | undefined,
25
+ ): { title: string; body: string; open: string | null } | null;
@@ -31,6 +31,25 @@ export function atTheApp({ focused = false, idleMs = 0 } = {}) {
31
31
  return !!focused && idleMs < AWAY_AFTER_MS;
32
32
  }
33
33
 
34
+ // WHAT COUNTS AS NEWS, which is the question before "is she away".
35
+ //
36
+ // renderer/src/App.tsx owns the definition of the inbox and asks this of every
37
+ // row in it, against the ids it remembered from the snapshot before. `known` is
38
+ // those ids, and `null` means there has not been a snapshot yet: on the app's
39
+ // first look, everything waiting has been waiting, so none of it is a banner.
40
+ //
41
+ // EMPTY AND NEVER-LOOKED ARE DIFFERENT FACTS, and reading one as the other is
42
+ // what sent this feature's best moment into the bin. App.tsx used to decide it
43
+ // had never looked by asking whether the ids it remembered were empty, which is
44
+ // also true of inbox zero — the state the whole product aims at. So the
45
+ // arrival that most deserved to reach her, the first one after she got clear,
46
+ // was the one the app silently dropped before any rule here could see it.
47
+ // tests/the-first-arrival-after-inbox-zero-still-speaks.test.mjs.
48
+ export function isNew(known, id) {
49
+ if (!known) return false;
50
+ return !known.has(id);
51
+ }
52
+
34
53
  // A title long enough to be a paragraph is normal here; hers run past a
35
54
  // hundred characters. macOS will hard-truncate mid-word, so cut on a space and
36
55
  // say that it was cut.
@@ -1,5 +1,6 @@
1
1
  // A command is an operation, never prose asking a model to imitate a CLI.
2
2
  import { CLAUDE_COMMANDS, commandWords } from './claude-commands.mjs';
3
+ import { harnessDefinition } from './harness-definitions.mjs';
3
4
  const command = (name, description, argumentHint = null, aliases = []) => ({ name, description, menuDescription: null, whole: true, argumentHint, aliases });
4
5
  export const CODEX_COMMANDS = [
5
6
  command('model','Show models or change this task’s model','[model]'),
@@ -23,12 +24,18 @@ export const LOCAL_COMMANDS = CODEX_COMMANDS.filter(c=>['model','effort','diff',
23
24
  // is refused on Codex for now, in a sentence that says why, because that call
24
25
  // has never been made against a real server here.
25
26
  const FORK = command('fork','Start a new task from this conversation','<what to try instead>');
27
+ const COMMAND_MENUS = {
28
+ codex: CODEX_COMMANDS,
29
+ claude: [command('remote-control','Continue this conversation in Claude on another device',null,['rc']),FORK,...CLAUDE_COMMANDS.map(c=>LOCAL_COMMANDS.find(l=>l.name===c.name)??c),...LOCAL_COMMANDS.filter(l=>!CLAUDE_COMMANDS.some(c=>c.name===l.name))],
30
+ local: LOCAL_COMMANDS,
31
+ };
26
32
  export function providerCommands(engine) {
27
33
  // NOT ON THE CODEX MENU, because this menu's rule is that it offers nothing
28
34
  // that engine cannot run, and Codex forking is not connected yet. Typed there
29
35
  // it gets the ordinary unavailable refusal, which points at the native Codex
30
36
  // client, where `codex fork` genuinely works.
31
- return engine==='codex' ? CODEX_COMMANDS : [command('remote-control','Continue this conversation in Claude on another device',null,['rc']),FORK,...CLAUDE_COMMANDS.map(c=>LOCAL_COMMANDS.find(l=>l.name===c.name)??c),...LOCAL_COMMANDS.filter(l=>!CLAUDE_COMMANDS.some(c=>c.name===l.name))];
37
+ const definition = harnessDefinition(engine) ?? harnessDefinition();
38
+ return COMMAND_MENUS[definition.commandMenu] ?? [];
32
39
  }
33
40
  export function providerCommand(text, engine) {
34
41
  if(typeof text!=='string')return null;
@@ -38,7 +45,8 @@ export function providerCommand(text, engine) {
38
45
  const cmd=providerCommands(engine).find(c=>commandWords(c).includes(word));
39
46
  if(!cmd) return {name:word,args,route:'unavailable'};
40
47
  if(cmd.name==='fork')return {name:'fork',args,route:'fork'};
41
- return {name:cmd.name,args,route:cmd.name==='remote-control'?'remote':engine==='codex'?'codex':LOCAL_COMMANDS.some(c=>c.name===cmd.name)?'local':'claude'};
48
+ const route = (harnessDefinition(engine) ?? harnessDefinition()).commandRoute;
49
+ return {name:cmd.name,args,route:cmd.name==='remote-control'?'remote':route==='codex'?'codex':LOCAL_COMMANDS.some(c=>c.name===cmd.name)?'local':route};
42
50
  }
43
51
  export function reviewTarget(args='') {
44
52
  args=args.trim();
package/shared/rank.d.mts CHANGED
@@ -18,3 +18,5 @@ export function landingIndex(
18
18
  x: number, y: number,
19
19
  ): number;
20
20
  export function moveProduct(order: string[], slug: string, toIndex: number): string[];
21
+ export function placedScore(place: number | null | undefined, natural: number): number;
22
+ export function dropPlaces(rows: Array<{ id: string; score: number }>, from: number, to: number): Record<string, number> | null;
package/shared/rank.mjs CHANGED
@@ -155,6 +155,56 @@ export function landingIndex(rects, x, y) {
155
155
  return rects.length;
156
156
  }
157
157
 
158
+ /**
159
+ * A THREAD'S OWN PLACE, SET BY DRAGGING IT IN THE LIST (w-6e5b532a95,
160
+ * 2026-10-07). Her words: "sometimes there are tasks that are higher priority
161
+ * than other projects and it's hard to articulate. I'd rather be able to drag
162
+ * things around here ... and have that priority remembered and that's how the
163
+ * app processes it." A project's place and a thread's level could never say
164
+ * that one Low thread matters more today than a whole project above it.
165
+ *
166
+ * So a dragged thread carries a PLACE: a number on the same scale as the score
167
+ * it would otherwise have, which replaces that score outright. It is a number
168
+ * and not a list of ids so that everything it was not dragged past keeps
169
+ * sorting the way it always did: a new Urgent thread still lands by its level,
170
+ * next to the placed ones rather than under all of them.
171
+ */
172
+ export function placedScore(place, natural) {
173
+ return Number.isFinite(place) ? place : natural;
174
+ }
175
+
176
+ /**
177
+ * The places a drop writes. `rows` is the list as drawn, top first, each with
178
+ * the score it sorts by (its place, or its project and level); `from` is the
179
+ * dragged row and `to` the row it was let go in front of, or the length for
180
+ * the end, both counted in `rows`. Null when it lands where it was.
181
+ *
182
+ * The dragged row goes between its new neighbours. When those two TIE (two
183
+ * Medium threads in one project, whose order is only which changed last) no
184
+ * number fits between them, so the tied rows above the drop are given places
185
+ * of their own first, spread in the order they were drawn. The rows below are
186
+ * never written.
187
+ */
188
+ export function dropPlaces(rows, from, to) {
189
+ if (!Array.isArray(rows) || from < 0 || from >= rows.length) return null;
190
+ const rest = rows.filter((_, i) => i !== from);
191
+ const at = Math.max(0, Math.min(rest.length, to > from ? to - 1 : to));
192
+ if (at === from || !rest.length) return null;
193
+ const close = (a, b) => Math.abs(a - b) < 1e-6;
194
+ const below = rest[at]?.score;
195
+ const floor = below ?? rest[at - 1].score - 2;
196
+ // The run of rows just above the drop that tie with the floor.
197
+ let top = at;
198
+ while (top > 0 && close(rest[top - 1].score, floor)) top -= 1;
199
+ const ceiling = top > 0 ? rest[top - 1].score : floor + 2;
200
+ const run = rest.slice(top, at);
201
+ const step = (ceiling - floor) / (run.length + 2);
202
+ const out = {};
203
+ run.forEach((r, j) => { out[r.id] = floor + (run.length + 1 - j) * step; });
204
+ out[rows[from].id] = floor + step;
205
+ return out;
206
+ }
207
+
158
208
  /**
159
209
  * Move one product to a new index, returning a fresh order. Products that were
160
210
  * never in the order join it on their first move, so dragging an unranked chip