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,422 @@
|
|
|
1
|
+
// HER CLAUDE CODE AGENTS, THE FILES.
|
|
2
|
+
//
|
|
3
|
+
// names three different things on a Mac, and this module is one of them: the
|
|
4
|
+
// `.md` agent files Claude Code loads as subagents. It is the literal reading
|
|
5
|
+
// of her sentence, because Claude Code's own two scopes ARE all-projects and
|
|
6
|
+
// this-project:
|
|
7
|
+
// ~/.claude/agents/*.md every project on the Mac
|
|
8
|
+
// <project>/.claude/agents/*.md that project only
|
|
9
|
+
//
|
|
10
|
+
// Measured on her Mac 08-21: four in the first, four in `astral-desktop`,
|
|
11
|
+
// which is the only one of about thirty dev folders that has any.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THIS DOES NOT CLAIM. Claude Code reads the home folder set itself in
|
|
14
|
+
// every session it starts, whatever directory it is in, so nothing here makes
|
|
15
|
+
// an agent work that was not working already. What Agentbox gains is that it can
|
|
16
|
+
// finally SAY what she has, which it could not do at all: no file under main/
|
|
17
|
+
// or renderer/src mentioned `.claude/agents` before this one.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { GUARDED } from '../shared/project-folder-check.mjs';
|
|
23
|
+
import { RECENT_DAYS, isScratchFolder } from './agent-sessions.mjs';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A file bigger than this is not an agent definition, and reading a gigabyte
|
|
27
|
+
* of something because it was dropped in the folder is not worth the risk. */
|
|
28
|
+
const MAX_BYTES = 256 * 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* AND A FILE OLDER THAN THIS IS NOT AN AGENT SHE IS IMPORTING.
|
|
32
|
+
*
|
|
33
|
+
* The four rows in that shot are the whole of `~/.claude/agents` on her Mac and
|
|
34
|
+
* they were last written on 23 and 30 March, five months before the card that
|
|
35
|
+
* offered them.
|
|
36
|
+
*
|
|
37
|
+
* NOTHING STOPS WORKING WHEN A FILE FALLS OUT OF THE WINDOW. Claude Code loads
|
|
38
|
+
* `~/.claude/agents` itself in every session it starts, which the header of
|
|
39
|
+
* this file has said since it was written. Dropping a March file off the import
|
|
40
|
+
* card takes it off a list; it does not take it off the Mac. */
|
|
41
|
+
const RECENT_MS = RECENT_DAYS * 24 * 60 * 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* THE FRONT MATTER, and only the parts an agent screen can show. Claude Code
|
|
45
|
+
* writes `name`, `description` and `tools` between two `---` fences. A file
|
|
46
|
+
* with no fence is still an agent as far as Claude Code is concerned, so it
|
|
47
|
+
* is kept and named after itself rather than dropped. */
|
|
48
|
+
export function parseAgentFile(text, file = '') {
|
|
49
|
+
const fallback = path.basename(file, '.md');
|
|
50
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text ?? '');
|
|
51
|
+
if (!m) return { name: fallback, description: firstFromBody(text, fallback) };
|
|
52
|
+
const out = {};
|
|
53
|
+
let key = null;
|
|
54
|
+
for (const raw of m[1].split(/\r?\n/)) {
|
|
55
|
+
// A description can run onto its own indented lines, which is how Claude
|
|
56
|
+
// Code writes the long ones. An indented line with no key continues the
|
|
57
|
+
// key above it rather than starting a new one.
|
|
58
|
+
const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(raw);
|
|
59
|
+
if (kv && !/^\s/.test(raw)) {
|
|
60
|
+
key = kv[1];
|
|
61
|
+
out[key] = kv[2].trim();
|
|
62
|
+
} else if (key && raw.trim()) {
|
|
63
|
+
out[key] = `${out[key]} ${raw.trim()}`.trim();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const name = strip(out.name) || fallback;
|
|
67
|
+
// TAKE WHAT WE CAN GET.
|
|
68
|
+
//
|
|
69
|
+
// A `description` in the front matter is what Claude Code writes and what
|
|
70
|
+
// her own eight all have. It is not guaranteed: an agent file is a markdown
|
|
71
|
+
// file and a person can write one by hand with nothing but a heading in it.
|
|
72
|
+
// So when the front matter has no description, the body is read for the next
|
|
73
|
+
// best thing rather than the row being left blank.
|
|
74
|
+
const description = strip(out.description) || firstFromBody(text.slice(m[0].length), name);
|
|
75
|
+
return { name, description };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Two strings that are the same thing said two ways: the file name, the front
|
|
80
|
+
* matter name and a heading all reach the screen and are often one word apart.
|
|
81
|
+
* Letters and digits only, so `Docs Reviewer`, `docs-reviewer` and
|
|
82
|
+
* `Docs reviewer.` are one string. */
|
|
83
|
+
function sameThing(a, b) {
|
|
84
|
+
const k = (v) => (v ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
85
|
+
return !!k(a) && k(a) === k(b);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* THE NEXT BEST THING AFTER THE DESCRIPTION, out of the markdown body: the
|
|
90
|
+
* first heading, and failing that the first line of prose. Both are what a
|
|
91
|
+
* person wrote for a person, which is the whole reason they beat nothing.
|
|
92
|
+
*
|
|
93
|
+
* `avoid` is the agent's own name. Most hand written agent files open
|
|
94
|
+
* `# Docs Reviewer`, and a heading that is the name again is not information,
|
|
95
|
+
* so in that one case the prose under it is taken instead of the heading.
|
|
96
|
+
*
|
|
97
|
+
* What is skipped on the way: blank lines, code fences and anything inside
|
|
98
|
+
* one, list bullets, quotes and horizontal rules. A row reading "```bash" or
|
|
99
|
+
* "---" says less than an empty row does. */
|
|
100
|
+
export function firstFromBody(text, avoid = '') {
|
|
101
|
+
const lines = (text ?? '').split(/\r?\n/);
|
|
102
|
+
let heading = '';
|
|
103
|
+
let prose = '';
|
|
104
|
+
let fenced = false;
|
|
105
|
+
for (const raw of lines) {
|
|
106
|
+
const t = raw.trim();
|
|
107
|
+
if (/^(```|~~~)/.test(t)) { fenced = !fenced; continue; }
|
|
108
|
+
if (fenced || !t) continue;
|
|
109
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(t)) continue;
|
|
110
|
+
const h = /^#{1,6}\s+(.*)$/.exec(t);
|
|
111
|
+
if (h) {
|
|
112
|
+
// The first heading wins, and it is usually the agent's own name written
|
|
113
|
+
// out. It is still better than a blank row, so it is taken and the
|
|
114
|
+
// screen's own de-duplication decides whether to show it.
|
|
115
|
+
if (!heading) heading = h[1].trim();
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (/^([-*+]\s|>\s|\d+[.)]\s|<)/.test(t)) continue;
|
|
119
|
+
if (!prose) prose = t;
|
|
120
|
+
if (heading) break;
|
|
121
|
+
}
|
|
122
|
+
const pick = sameThing(heading, avoid) ? (prose || heading) : (heading || prose);
|
|
123
|
+
return strip(pick).replace(/[*_`#]/g, '').trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function strip(v) {
|
|
127
|
+
const s = (v ?? '').trim();
|
|
128
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
129
|
+
return s.slice(1, -1).trim();
|
|
130
|
+
}
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* ONE SENTENCE OF IT, and not the instruction to Claude at the front of it.
|
|
136
|
+
*
|
|
137
|
+
* Her descriptions are written to be read by an agent deciding whether to run,
|
|
138
|
+
* so five of her eight open "Use this agent to ...". Four of those in a column
|
|
139
|
+
* is four rows that start with the same three words and say nothing until the
|
|
140
|
+
* fourth. The opening is cut ONLY in the form that leaves a whole sentence
|
|
141
|
+
* behind it, so "Use this skill AFTER completing ..." is left exactly as its
|
|
142
|
+
* author wrote it rather than beheaded into nonsense.
|
|
143
|
+
*
|
|
144
|
+
* The width of the row is the real limit and CSS does that cut; `max` here is
|
|
145
|
+
* only a stop on a sentence long enough to be worth cutting in the string. */
|
|
146
|
+
export function oneLine(description, max = 160) {
|
|
147
|
+
const d = (description ?? '')
|
|
148
|
+
.replace(/\s+/g, ' ')
|
|
149
|
+
.trim()
|
|
150
|
+
.replace(/^use th(?:is|e) (?:agent|skill|subagent) to (\w)/i, (_m, c) => c.toUpperCase());
|
|
151
|
+
if (!d) return '';
|
|
152
|
+
const stop = d.search(/[.!?](\s|$)/);
|
|
153
|
+
const first = stop > 0 ? d.slice(0, stop + 1) : d;
|
|
154
|
+
if (first.length <= max) return first;
|
|
155
|
+
const cut = first.slice(0, max);
|
|
156
|
+
const sp = cut.lastIndexOf(' ');
|
|
157
|
+
return `${(sp > 40 ? cut.slice(0, sp) : cut).replace(/[,;:]$/, '')}...`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* THE TITLE ON THE SCREEN. A file is named `leon-okafor-qa` and a person is
|
|
162
|
+
* not, so the hyphens come out and the first letters go up. Anything already
|
|
163
|
+
* carrying a capital or a space is left exactly as its author wrote it.
|
|
164
|
+
*
|
|
165
|
+
* The short words stay shouted, because "Leon Okafor Qa" is not what she
|
|
166
|
+
* called it and a screen that renames her own agents is worse than one that
|
|
167
|
+
* shows the file name raw. */
|
|
168
|
+
const SHOUTED = new Set(['qa', 'ui', 'ux', 'api', 'cli', 'ai', 'db', 'pr', 'seo', 'css', 'html', 'sql', 'mcp']);
|
|
169
|
+
|
|
170
|
+
export function agentTitle(name) {
|
|
171
|
+
const n = (name ?? '').trim();
|
|
172
|
+
if (!n || /[A-Z\s]/.test(n)) return n;
|
|
173
|
+
return n
|
|
174
|
+
.replace(/[-_]+/g, ' ')
|
|
175
|
+
.split(' ')
|
|
176
|
+
.map((w) => (SHOUTED.has(w) ? w.toUpperCase() : w.replace(/^\w/, (c) => c.toUpperCase())))
|
|
177
|
+
.join(' ');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function readDir(dir, scope, { now = Date.now() } = {}) {
|
|
181
|
+
let names = [];
|
|
182
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
183
|
+
const out = [];
|
|
184
|
+
for (const f of names.sort()) {
|
|
185
|
+
if (!f.endsWith('.md') || f.startsWith('.')) continue;
|
|
186
|
+
const full = path.join(dir, f);
|
|
187
|
+
let text = '';
|
|
188
|
+
try {
|
|
189
|
+
const st = fs.statSync(full);
|
|
190
|
+
if (st.size > MAX_BYTES) continue;
|
|
191
|
+
if (now - st.mtimeMs > RECENT_MS) continue;
|
|
192
|
+
text = fs.readFileSync(full, 'utf8');
|
|
193
|
+
} catch { continue; }
|
|
194
|
+
const { name, description } = parseAgentFile(text, full);
|
|
195
|
+
if (!name) continue;
|
|
196
|
+
const title = agentTitle(name);
|
|
197
|
+
// A HEADING THAT IS JUST THE NAME AGAIN IS NOT INFORMATION. Most hand
|
|
198
|
+
// written agent files open `# Docs Reviewer`, so the fallback above would
|
|
199
|
+
// put the row's own title underneath it in smaller grey type. When what we
|
|
200
|
+
// could get turns out to be the name, the row simply has no second line.
|
|
201
|
+
const line = oneLine(description);
|
|
202
|
+
out.push({ name, title, line: sameThing(line, title) ? '' : line, scope, path: full });
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* BOTH SCOPES, in the order the screen shows them. `folder` is the project's
|
|
209
|
+
* own directory and may be null, which is the case where she has not pointed
|
|
210
|
+
* Agentbox at any code yet and only the home folder set exists. */
|
|
211
|
+
export function readAgentFiles({ home = os.homedir(), folder = null, now = Date.now() } = {}) {
|
|
212
|
+
const user = readDir(path.join(home, '.claude', 'agents'), 'all', { now });
|
|
213
|
+
const project = folder
|
|
214
|
+
? readDir(path.join(folder, '.claude', 'agents'), 'project', { now })
|
|
215
|
+
: [];
|
|
216
|
+
return { user, project };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* ---------------------- THE FOLDERS SHE HAS NOT NAMED ---------------------- */
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* WHERE ELSE ON THIS MAC AN AGENT LIVES.
|
|
223
|
+
*
|
|
224
|
+
* That settles the scope question and it exposes the gap underneath it. A
|
|
225
|
+
* folder agent only works inside its own folder, so it can never be carried
|
|
226
|
+
* into some other project's inbox and nothing here tries to. What measured on
|
|
227
|
+
* her Mac 2026-08-25, four agents in `~/.claude/agents`, four in
|
|
228
|
+
* `~/Desktop/dev/astral-desktop/.claude/agents`, and no project of hers points
|
|
229
|
+
* at that folder. `readAgentFiles` can only ever look in the folder it is
|
|
230
|
+
* handed, so no choice on the card reached those four.
|
|
231
|
+
*
|
|
232
|
+
* This looks for the rest, so the card can offer to make the project that
|
|
233
|
+
* keeps them where they started.
|
|
234
|
+
*
|
|
235
|
+
* A DOT FOLDER IS NEVER A PROJECT, and that is not a guess about tidiness. The
|
|
236
|
+
* same scan over her home folder finds
|
|
237
|
+
* `~/.cursor/extensions/esbenp.prettier-vscode-12.4.0-universal/.claude/agents`
|
|
238
|
+
* with two files in it, which is an editor extension shipping its own and not
|
|
239
|
+
* a folder she works in. Skipping names that begin with a dot is what keeps it
|
|
240
|
+
* off the card. */
|
|
241
|
+
|
|
242
|
+
/** Folder names that are never a project and are expensive to walk. */
|
|
243
|
+
const NOT_A_PROJECT = new Set([
|
|
244
|
+
'node_modules', 'Library', 'Applications', 'Movies', 'Music', 'Pictures',
|
|
245
|
+
'Public', 'Photos Library.photoslibrary',
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* THE FOLDERS macOS GUARDS, AS FULL PATHS, for this walk to refuse to enter.
|
|
250
|
+
*
|
|
251
|
+
* The walk below used to queue every directory under her home folder, and
|
|
252
|
+
* measured on her Mac 2026-08-30 that is 122 of them, three of which are
|
|
253
|
+
* `Desktop`, `Documents` and `Downloads`. macOS answers each with a consent
|
|
254
|
+
* panel carrying our name, which is the panel in her screenshot. It returned
|
|
255
|
+
* nothing for the two panels it cost: no folder on her Mac outside her home
|
|
256
|
+
* set has agents of its own today.
|
|
257
|
+
*
|
|
258
|
+
* `main/first-run.mjs` learned this on 2026-08-25 and `shared/project-folder-
|
|
259
|
+
* check.mjs` holds the list, and this module simply never got the guard.
|
|
260
|
+
*
|
|
261
|
+
* MATCHED AS WHOLE PATHS DIRECTLY UNDER HOME, never by name, which is the rule
|
|
262
|
+
* first-run.mjs states: a folder called `Documents` inside somebody's code
|
|
263
|
+
* folder is their folder, and `~/Documents` is not. A project INSIDE a guarded
|
|
264
|
+
* folder is still reachable, by the door below rather than by a walk. */
|
|
265
|
+
function guardedPaths(home) {
|
|
266
|
+
const here = path.resolve(String(home ?? ''));
|
|
267
|
+
return here ? new Set(GUARDED.map((n) => path.join(here, n))) : new Set();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* WHERE CLAUDE CODE ITSELF SAYS SHE WORKS.
|
|
272
|
+
*
|
|
273
|
+
* It is not a place on the disk, it is a file. Claude Code keeps one key per
|
|
274
|
+
* directory it has ever run in under `projects` in `~/.claude.json`, absolute
|
|
275
|
+
* paths, and reading it needs no permission from anybody: it sits at the top of
|
|
276
|
+
* the home folder, outside every guarded one. Measured on her Mac 2026-08-30:
|
|
277
|
+
* 46 keys, and they name her dev folders exactly, including the ones the walk
|
|
278
|
+
* above can no longer reach because they are under `~/Desktop`.
|
|
279
|
+
*
|
|
280
|
+
* So a folder inside a guarded folder is still offered, on the strength of
|
|
281
|
+
* Claude Code having run there rather than on the strength of us looking. That
|
|
282
|
+
* is the difference she is asking for: Agentbox reaches for one folder she has
|
|
283
|
+
* worked in, not for the whole of Documents.
|
|
284
|
+
*
|
|
285
|
+
* A temp directory is not a project and neither is a worktree Claude made for
|
|
286
|
+
* itself, so `isScratchFolder` and the dot-folder rule throw those out here the
|
|
287
|
+
* same way the walk throws them out below. */
|
|
288
|
+
export function claudeProjectFolders({ home = os.homedir() } = {}) {
|
|
289
|
+
const here = path.resolve(String(home ?? ''));
|
|
290
|
+
let parsed = null;
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(fs.readFileSync(path.join(here, '.claude.json'), 'utf8'));
|
|
293
|
+
} catch { return []; }
|
|
294
|
+
const keys = Object.keys(parsed?.projects ?? {});
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const key of keys) {
|
|
297
|
+
if (typeof key !== 'string' || !path.isAbsolute(key)) continue;
|
|
298
|
+
const full = path.resolve(key);
|
|
299
|
+
if (full === here) continue;
|
|
300
|
+
if (isScratchFolder(full, here)) continue;
|
|
301
|
+
// `~/Desktop/claude/.claude/worktrees/upbeat-matsumoto-588a8d` is a real key
|
|
302
|
+
// and it is Claude Code's own scratch checkout, not a folder she opens.
|
|
303
|
+
if (full.split(path.sep).some((seg) => seg.startsWith('.'))) continue;
|
|
304
|
+
out.push(full);
|
|
305
|
+
}
|
|
306
|
+
return out;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* How many agent files sit in one `.claude/agents`, without reading any of
|
|
311
|
+
* them. The card needs the count and nothing else until she picks a folder.
|
|
312
|
+
*
|
|
313
|
+
* The age window is applied here too, because a count and the list underneath
|
|
314
|
+
* it are the same fact said twice and a heading reading "four" over an empty
|
|
315
|
+
* list is worse than either. */
|
|
316
|
+
function countAgentFiles(dir, now = Date.now()) {
|
|
317
|
+
try {
|
|
318
|
+
return fs.readdirSync(dir).filter((f) => {
|
|
319
|
+
if (!f.endsWith('.md') || f.startsWith('.')) return false;
|
|
320
|
+
try { return now - fs.statSync(path.join(dir, f)).mtimeMs <= RECENT_MS; } catch { return false; }
|
|
321
|
+
}).length;
|
|
322
|
+
} catch { return 0; }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Every folder under `roots` that has agents of its own.
|
|
327
|
+
*
|
|
328
|
+
* `roots` is whatever the app can honestly derive: the parent of each project
|
|
329
|
+
* folder she has already pointed Agentbox at, which is how her own dev folder is
|
|
330
|
+
* found without this module guessing at Mac folder conventions. Home and
|
|
331
|
+
* `~/Desktop` are always included, because a first run has no projects yet and
|
|
332
|
+
* a scan that finds nothing is worse than one that looks in two obvious places.
|
|
333
|
+
*
|
|
334
|
+
* HOME ITSELF IS NEVER RETURNED. `~/.claude/agents` is Claude Code's every
|
|
335
|
+
* project scope and the card already has it on the other side of the switch;
|
|
336
|
+
* returning it here would draw it twice under two different promises.
|
|
337
|
+
*
|
|
338
|
+
* Bounded on purpose: two levels below each root and a hard stop at `max`
|
|
339
|
+
* directories, so a Mac with a deep folder tree cannot turn opening a card
|
|
340
|
+
* into a disk walk. */
|
|
341
|
+
export function findAgentFolders({
|
|
342
|
+
home = os.homedir(), roots = [], depth = 2, max = 4000, now = Date.now(),
|
|
343
|
+
named = null,
|
|
344
|
+
} = {}) {
|
|
345
|
+
const here = path.resolve(home);
|
|
346
|
+
const guarded = guardedPaths(here);
|
|
347
|
+
// `~/Desktop` WAS A SEED AND IS NOT ANY MORE. It was here because a first run
|
|
348
|
+
// has no projects to take roots from, and the price of that guess was a
|
|
349
|
+
// consent panel on a folder she had not mentioned. What replaces it is the
|
|
350
|
+
// list Claude Code keeps of the folders it has actually run in, which reaches
|
|
351
|
+
// `~/Desktop/dev/astral-desktop` by name and asks about nothing else.
|
|
352
|
+
const seeds = [here, ...(Array.isArray(roots) ? roots : [])]
|
|
353
|
+
.filter((r) => typeof r === 'string' && r.trim())
|
|
354
|
+
.map((r) => path.resolve(r))
|
|
355
|
+
// A project sitting directly in `~/Desktop` hands this walk `~/Desktop` as
|
|
356
|
+
// its root, which is the guarded folder again by another door.
|
|
357
|
+
.filter((r) => !guarded.has(r));
|
|
358
|
+
const found = new Map();
|
|
359
|
+
const seen = new Set();
|
|
360
|
+
const queue = seeds.map((dir) => ({ dir, left: depth }));
|
|
361
|
+
let looked = 0;
|
|
362
|
+
// THE FOLDERS CLAUDE CODE NAMES ARE PROBED, NEVER ENUMERATED. One `readdir`
|
|
363
|
+
// of one `.claude/agents` inside a folder she works in, and no listing of the
|
|
364
|
+
// folder it is in or of anything above it.
|
|
365
|
+
const byName = Array.isArray(named) ? named : claudeProjectFolders({ home: here });
|
|
366
|
+
for (const dir of byName) {
|
|
367
|
+
const full = path.resolve(dir);
|
|
368
|
+
if (full === here || seen.has(full)) continue;
|
|
369
|
+
seen.add(full);
|
|
370
|
+
const n = countAgentFiles(path.join(full, '.claude', 'agents'), now);
|
|
371
|
+
if (n > 0) found.set(full, n);
|
|
372
|
+
}
|
|
373
|
+
while (queue.length) {
|
|
374
|
+
const { dir, left } = queue.shift();
|
|
375
|
+
if (seen.has(dir)) continue;
|
|
376
|
+
seen.add(dir);
|
|
377
|
+
if (++looked > max) break;
|
|
378
|
+
if (dir !== here) {
|
|
379
|
+
const n = countAgentFiles(path.join(dir, '.claude', 'agents'), now);
|
|
380
|
+
if (n > 0) found.set(dir, n);
|
|
381
|
+
}
|
|
382
|
+
if (left <= 0) continue;
|
|
383
|
+
let entries = [];
|
|
384
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
385
|
+
for (const e of entries) {
|
|
386
|
+
// isDirectory is false for a symlink, which is also how this cannot
|
|
387
|
+
// walk in a circle.
|
|
388
|
+
if (!e.isDirectory()) continue;
|
|
389
|
+
if (e.name.startsWith('.') || NOT_A_PROJECT.has(e.name)) continue;
|
|
390
|
+
const child = path.join(dir, e.name);
|
|
391
|
+
// The guard. macOS asks about each of these separately and this walk has
|
|
392
|
+
// no business in any of them.
|
|
393
|
+
if (guarded.has(child)) continue;
|
|
394
|
+
queue.push({ dir: child, left: left - 1 });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return [...found]
|
|
398
|
+
.map(([folder, count]) => ({ folder, count, short: shortPath(folder, here), name: path.basename(folder) }))
|
|
399
|
+
.sort((a, b) => a.folder.localeCompare(b.folder));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* `/Users/you/Desktop/dev/astral-desktop` → `~/Desktop/dev/astral-desktop`. The
|
|
404
|
+
* card prints this, and a path with her account name in it reads like a system
|
|
405
|
+
* string rather than a folder she recognises. */
|
|
406
|
+
export function shortPath(folder, home = os.homedir()) {
|
|
407
|
+
const here = path.resolve(home);
|
|
408
|
+
const full = path.resolve(folder ?? '');
|
|
409
|
+
return full === here || full.startsWith(`${here}/`) ? `~${full.slice(here.length)}` : full;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* THE AGENTS INSIDE ONE FOLDER, read the same way a project's own side is.
|
|
414
|
+
*
|
|
415
|
+
* The line she skipped could only say a number, because `findAgentFolders`
|
|
416
|
+
* counts files and never opens one. A folder that is going to be a heading on
|
|
417
|
+
* the card with its agents listed under it needs the agents themselves, so
|
|
418
|
+
* this reads them. Same `readDir`, same scope tag as a project's own folder,
|
|
419
|
+
* because that is exactly what this folder is about to become. */
|
|
420
|
+
export function readFolderAgents(folder, { now = Date.now() } = {}) {
|
|
421
|
+
return folder ? readDir(path.join(folder, '.claude', 'agents'), 'project', { now }) : [];
|
|
422
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// WHEN SHE ASKED TO SEE AN AGENT AGAIN, AND WHEN SHE ASKED NOT TO. One small
|
|
2
|
+
// durable map, and the only state the app keeps about a session it did not start.
|
|
3
|
+
//
|
|
4
|
+
// Three facts per agent and they are different facts. `runAt` is a moment she
|
|
5
|
+
// named and it passes on its own. It used to be a watermark compared against
|
|
6
|
+
// the session's own activity; `putAway` in shared/agents.mjs carries the whole
|
|
7
|
+
// story of why it is not. Neither sends anything, and neither touches the
|
|
8
|
+
// process — both are about her inbox.
|
|
9
|
+
//
|
|
10
|
+
// `repliedAt` is the third, and it is the moment she SPOKE to the session.It
|
|
11
|
+
// had not, because nothing anywhere remembered that she had said anything. The
|
|
12
|
+
// message went into somebody's terminal and her own list looked identical
|
|
13
|
+
// afterwards, which is the same shape as a message being swallowed.
|
|
14
|
+
//
|
|
15
|
+
// Unlike the other two this one is not only about her inbox — it is the record
|
|
16
|
+
// of a thing she did — but it is kept here for the same reason they are: there
|
|
17
|
+
// is no ledger an agent row could be written to. It is compared against the
|
|
18
|
+
// session's own `statusAt` (`tookHerReply` in shared/agents.mjs), so a session
|
|
19
|
+
// that comes back to a stop AFTER she spoke ends the reply rather than this
|
|
20
|
+
// having to expire.
|
|
21
|
+
//
|
|
22
|
+
// It is here, and not in the ledger, because there is no ledger to put it in:
|
|
23
|
+
// an agent row stands for somebody's terminal, it belongs to no product, and a
|
|
24
|
+
// write against `agent:41234` would append a line to a real product's
|
|
25
|
+
// work-items.jsonl for an item that does not exist (main/ipc.mjs refuses
|
|
26
|
+
// exactly that, and still does).
|
|
27
|
+
//
|
|
28
|
+
// Machine-local on purpose. The agents this defers are processes on THIS
|
|
29
|
+
// machine, read out of `ps`; carrying the moment to another machine would carry
|
|
30
|
+
// it away from the only thing it is about.
|
|
31
|
+
//
|
|
32
|
+
// Nothing here fires. The moment is a predicate the reader asks about every
|
|
33
|
+
// measurement (`putOff` in shared/agents.mjs), so a moment missed while the
|
|
34
|
+
// laptop was shut is simply past when the lid opens, and the row is back.
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
// Imported rather than re-derived: what identifies an agent is a rule, and
|
|
39
|
+
// there is one copy of it.
|
|
40
|
+
import { agentKey } from '../shared/agents.mjs';
|
|
41
|
+
|
|
42
|
+
const num = (v) => (Number.isFinite(Number(v)) ? Math.max(0, Math.trunc(Number(v))) : 0);
|
|
43
|
+
|
|
44
|
+
// HER SIDE OF A THREAD IS SMALL, and this file is read on every draw. Measured
|
|
45
|
+
// across her 16 real sessions on 2026-08-17: 155 turns of hers in total, median
|
|
46
|
+
// 6 per session, median 131 characters. The card shows three. Eight, at 600
|
|
47
|
+
// characters, is far more than anything draws and small enough that a session
|
|
48
|
+
// left running for a month cannot grow the file without bound.
|
|
49
|
+
const KEEP_SPOKE = 8;
|
|
50
|
+
const SPOKE_CHARS = 600;
|
|
51
|
+
|
|
52
|
+
function trimSpoke(list) {
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const t of Array.isArray(list) ? list : []) {
|
|
55
|
+
const at = num(t?.at);
|
|
56
|
+
const text = String(t?.text ?? '').trim().slice(0, SPOKE_CHARS);
|
|
57
|
+
if (at > 0 && text) out.push({ at, text });
|
|
58
|
+
}
|
|
59
|
+
out.sort((a, b) => a.at - b.at);
|
|
60
|
+
return out.slice(-KEEP_SPOKE);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class AgentSchedule {
|
|
64
|
+
constructor(root) {
|
|
65
|
+
this.file = path.join(root, '.zero-agent-schedule.json');
|
|
66
|
+
this.byKey = this._read();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_read() {
|
|
70
|
+
try {
|
|
71
|
+
const raw = JSON.parse(fs.readFileSync(this.file, 'utf8'));
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [key, value] of Object.entries(raw ?? {})) {
|
|
74
|
+
const runAt = num(value?.runAt);
|
|
75
|
+
const doneThrough = num(value?.doneThrough);
|
|
76
|
+
const repliedAt = num(value?.repliedAt);
|
|
77
|
+
const spoke = trimSpoke(value?.spoke);
|
|
78
|
+
// The same four-way test as `_keep`. an entry holding only `spoke` is
|
|
79
|
+
// the record of a conversation, and this used to write it to disk and
|
|
80
|
+
// then drop it on the next read.
|
|
81
|
+
if (runAt > 0 || doneThrough > 0 || repliedAt > 0 || spoke.length) {
|
|
82
|
+
out[key] = { runAt, doneThrough, repliedAt, spoke, setAt: num(value?.setAt) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
} catch {
|
|
87
|
+
// No file, or a half-written one. An empty map reads as "nothing is put
|
|
88
|
+
// off", which is the state of every machine on the day this shipped.
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_write() {
|
|
94
|
+
try {
|
|
95
|
+
fs.writeFileSync(this.file, JSON.stringify(this.byKey));
|
|
96
|
+
} catch {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The moment set on one agent, or 0. */
|
|
100
|
+
runAt(key) {
|
|
101
|
+
return this.byKey[key]?.runAt ?? 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The activity she has already seen and closed, or 0. */
|
|
105
|
+
doneThrough(key) {
|
|
106
|
+
return this.byKey[key]?.doneThrough ?? 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** When she last spoke to this session from Agentbox, or 0. */
|
|
110
|
+
repliedAt(key) {
|
|
111
|
+
return this.byKey[key]?.repliedAt ?? 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_entry(key) {
|
|
115
|
+
return this.byKey[key] ?? { runAt: 0, doneThrough: 0, repliedAt: 0, spoke: [], setAt: 0 };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// An entry that says none of "later", "seen", "I spoke to it" or "here is what
|
|
119
|
+
// she said" is not an entry. The last of those four is why this is not the
|
|
120
|
+
// same list as it was: her words outlive the reply that carried them, so an
|
|
121
|
+
// entry holding only `spoke` is still the record of a conversation.
|
|
122
|
+
_keep(key, entry, now) {
|
|
123
|
+
if (entry.runAt > now || entry.doneThrough > 0 || entry.repliedAt > 0 || entry.spoke?.length) this.byKey[key] = entry;
|
|
124
|
+
else delete this.byKey[key];
|
|
125
|
+
this._write();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Put one off until `runAt`, or bring it back with 0.
|
|
130
|
+
*
|
|
131
|
+
* A moment already past is the same thing as no moment: the predicate would
|
|
132
|
+
* be false the instant it was written, so storing it would only leave a row
|
|
133
|
+
* in the file that means nothing and outlives the session it named.
|
|
134
|
+
*/
|
|
135
|
+
set(key, runAt, now = Date.now()) {
|
|
136
|
+
if (!key) return 0;
|
|
137
|
+
const when = Math.max(0, Math.trunc(Number(runAt) || 0));
|
|
138
|
+
const entry = this._entry(key);
|
|
139
|
+
this._keep(key, { ...entry, runAt: when > now ? when : 0, setAt: now }, now);
|
|
140
|
+
return when > now ? when : 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `through` is the agent's own last activity at the moment she closed it; 0
|
|
145
|
+
* undoes that and puts the row back, which is what her undo sends.
|
|
146
|
+
*
|
|
147
|
+
* ANY MARK ABOVE ZERO MEANS CLOSED.
|
|
148
|
+
*/
|
|
149
|
+
close(key, through, now = Date.now()) {
|
|
150
|
+
if (!key) return 0;
|
|
151
|
+
const mark = Math.max(0, Math.trunc(Number(through) || 0));
|
|
152
|
+
const entry = this._entry(key);
|
|
153
|
+
// CLOSING A ROW ENDS THE REPLY TOO. She is done with it here; leaving the
|
|
154
|
+
// mark standing would put it straight back into In progress the moment she
|
|
155
|
+
// undid the close, saying a session was working on something she had
|
|
156
|
+
// already finished with.
|
|
157
|
+
//
|
|
158
|
+
// BUT IT DOES NOT UNSAY WHAT SHE SAID. This used to empty `spoke` as well,
|
|
159
|
+
// and `spoke` is the ONLY record anywhere that she ever spoke to a session:
|
|
160
|
+
// Claude Code stamps an injected message `isMeta` and the transcript reader
|
|
161
|
+
// drops it, correctly, so nothing else on the machine knows. Measured on
|
|
162
|
+
// session-46: four of her messages reached that session and the file holds
|
|
163
|
+
// one, and its card was back to quoting only what she started it with five
|
|
164
|
+
// days earlier. Closing a row is about her inbox and nothing else, which is
|
|
165
|
+
// what the row itself promises her; it is not a licence to forget a
|
|
166
|
+
// conversation she can still open and read.
|
|
167
|
+
this._keep(key, {
|
|
168
|
+
...entry,
|
|
169
|
+
doneThrough: mark,
|
|
170
|
+
repliedAt: mark > 0 ? 0 : entry.repliedAt,
|
|
171
|
+
setAt: now,
|
|
172
|
+
}, now);
|
|
173
|
+
return mark;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* `at` is when; 0 clears it, which is what withdrawing a reply sends.
|
|
178
|
+
*
|
|
179
|
+
* Nothing here reaches the session — `main/agents.mjs` already did that, and
|
|
180
|
+
* this is only written once the message was actually handed over. A mark for
|
|
181
|
+
* a send that failed would move the row into In progress over a message that
|
|
182
|
+
* never arrived, which is the failure this codebase cares about most.
|
|
183
|
+
*/
|
|
184
|
+
replied(key, at, now = Date.now(), text = '') {
|
|
185
|
+
if (!key) return 0;
|
|
186
|
+
const mark = Math.max(0, Math.trunc(Number(at) || 0));
|
|
187
|
+
const entry = this._entry(key);
|
|
188
|
+
// WITHDRAWING TAKES THE WORDS BACK OUT OF THE CARD TOO. `replied(key, 0)`
|
|
189
|
+
// is the undo, and a card that still quoted the message underneath it
|
|
190
|
+
// would say she had said something she had just unsaid.
|
|
191
|
+
const spoke = mark === 0
|
|
192
|
+
? []
|
|
193
|
+
: trimSpoke([...(entry.spoke ?? []), ...(String(text).trim() ? [{ at: mark, text: String(text).trim() }] : [])]);
|
|
194
|
+
this._keep(key, { ...entry, repliedAt: mark, spoke, setAt: now }, now);
|
|
195
|
+
return mark;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* WHAT SHE ACTUALLY TYPED INTO THIS SESSION FROM AGENTBOX, oldest first.
|
|
200
|
+
*
|
|
201
|
+
* It is here because it is the only place that knows. Claude Code stamps a
|
|
202
|
+
* message injected over its socket `isMeta: true` and wraps it in "Another
|
|
203
|
+
* Claude session sent a message… not typed by y measured 2026-08-17 on
|
|
204
|
+
* session-67, where two messages she sent at 19:41 left the row saying only
|
|
205
|
+
* what she had started it with four days earlier.
|
|
206
|
+
*
|
|
207
|
+
* A another of her sessions pinging this one is a real peer and drawing it as
|
|
208
|
+
* her words would be a lie on the one card built to be trusted. So the record
|
|
209
|
+
* is written at the moment of delivery, by the only process that knows it
|
|
210
|
+
* delivered it.
|
|
211
|
+
*/
|
|
212
|
+
spoke(key) {
|
|
213
|
+
return this.byKey[key]?.spoke ?? [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Every live agent, carrying whatever moment she set on it.
|
|
218
|
+
*
|
|
219
|
+
* THE SWEEP IS NOT ALLOWED TO EAT THE FILE. A key whose moment has passed is
|
|
220
|
+
* inert and always goes: the predicate already ignores it. A key for a
|
|
221
|
+
* session that is not in this reading is a moment about a process that has
|
|
222
|
+
* exited — but only if the reading is real. `listAgents` answers [] on the
|
|
223
|
+
* first call of every boot (it hands back the cache and measures in the
|
|
224
|
+
* background) and again whenever a `ps` fails, and sweeping against either of
|
|
225
|
+
* those would clear every moment she has set, at every app start, with no
|
|
226
|
+
* symptom except her deferred rows all coming back.
|
|
227
|
+
*/
|
|
228
|
+
decorate(agents, now = Date.now()) {
|
|
229
|
+
const live = new Set();
|
|
230
|
+
const out = agents.map((agent) => {
|
|
231
|
+
const key = agentKey(agent);
|
|
232
|
+
live.add(key);
|
|
233
|
+
const entry = this.byKey[key];
|
|
234
|
+
if (!entry) return agent;
|
|
235
|
+
const out = { ...agent };
|
|
236
|
+
if (entry.runAt > now) { out.runAt = entry.runAt; out.runAtSetAt = entry.setAt; }
|
|
237
|
+
if (entry.doneThrough > 0) out.doneThrough = entry.doneThrough;
|
|
238
|
+
if (entry.repliedAt > 0) out.repliedAt = entry.repliedAt;
|
|
239
|
+
// the card quotes what she said last, and a session that has since
|
|
240
|
+
// stopped is exactly when she most needs to see it.
|
|
241
|
+
if (entry.spoke?.length) out.spoke = entry.spoke;
|
|
242
|
+
return out;
|
|
243
|
+
});
|
|
244
|
+
let dropped = false;
|
|
245
|
+
for (const [key, entry] of Object.entries(this.byKey)) {
|
|
246
|
+
const gone = live.size > 0 && !live.has(key);
|
|
247
|
+
if (!gone && (entry.runAt > now || entry.doneThrough > 0 || entry.repliedAt > 0 || entry.spoke?.length)) continue;
|
|
248
|
+
delete this.byKey[key];
|
|
249
|
+
dropped = true;
|
|
250
|
+
}
|
|
251
|
+
if (dropped) this._write();
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
}
|