@ravefox/space 0.0.0-stage → 0.2.1

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 (152) hide show
  1. package/LICENSE +205 -0
  2. package/NOTICE +55 -0
  3. package/README.md +63 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +63 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,61 @@
1
+ // D247: the model and effort a seat spawns with.
2
+ //
3
+ // Two rules live here so the run loop and its tests share them:
4
+ //
5
+ // 1. A listener seat (`agent run` with no daemon behind it) reads the model and
6
+ // effort its owner saved in Settings or on the add page from
7
+ // GET /api/agents/runtime/installations before each turn. A saved value
8
+ // replaces the one in the local record; an absent one leaves it alone, so an
9
+ // older server changes nothing.
10
+ // 2. An effort is dropped only when the device reported a NON-EMPTY list of
11
+ // levels that leaves it out. A device that reported nothing says nothing
12
+ // about the level, so the level is passed through and the harness decides.
13
+
14
+ const isControl = (value) => typeof value === 'string' && value.length > 0 && value.length <= 256;
15
+
16
+ const controlsOf = (row) => {
17
+ const controls = {};
18
+ if (isControl(row.runtimeControls.model)) controls.model = row.runtimeControls.model;
19
+ if (isControl(row.runtimeControls.effort)) controls.effort = row.runtimeControls.effort;
20
+ return Object.keys(controls).length ? controls : null;
21
+ };
22
+
23
+ /**
24
+ * The saved controls for this seat, or null when none are reported.
25
+ *
26
+ * A server that names `runtimeSourcePodId` (the installation Settings reads)
27
+ * is followed exactly: that row's controls, or null when it has none or the
28
+ * server names no source. An older server names none, and the seat reads its
29
+ * own pod's row, then any row of this instance that has controls.
30
+ */
31
+ export const savedRuntimeControls = ({ installations, podId, instanceId, runtimeSourcePodId } = {}) => {
32
+ if (!Array.isArray(installations)) return null;
33
+ const rows = installations.filter((row) => row?.type === 'installation'
34
+ && row.runtimeControls && typeof row.runtimeControls === 'object');
35
+ const wantedInstance = String(instanceId || 'default');
36
+ if (runtimeSourcePodId !== undefined) {
37
+ const source = runtimeSourcePodId === null ? null
38
+ : rows.find((entry) => String(entry.podId || '') === String(runtimeSourcePodId)
39
+ && String(entry.instanceId || 'default') === wantedInstance);
40
+ return source ? controlsOf(source) : null;
41
+ }
42
+ // /installations returns runtimeControls on every row, `{}` when nothing is
43
+ // saved, so both matches require a value: an empty row in this pod must not
44
+ // hide the controls saved on the identity's other installation.
45
+ const hasControls = (entry) => isControl(entry.runtimeControls.model) || isControl(entry.runtimeControls.effort);
46
+ const row = rows.find((entry) => String(entry.podId || '') === String(podId || '')
47
+ && String(entry.instanceId || 'default') === wantedInstance && hasControls(entry))
48
+ || rows.find((entry) => String(entry.instanceId || 'default') === wantedInstance && hasControls(entry));
49
+ return row ? controlsOf(row) : null;
50
+ };
51
+
52
+ /** The environment with saved controls laid over it. A null environment stays null. */
53
+ export const withSavedRuntimeControls = (environment, controls) => {
54
+ if (!environment || typeof environment !== 'object' || !controls) return environment;
55
+ return { ...environment, ...controls };
56
+ };
57
+
58
+ /** True only when the device listed its effort levels and this one is not among them. */
59
+ export const effortRefusedByDevice = (inventory, effort) => Boolean(effort)
60
+ && Array.isArray(inventory?.efforts) && inventory.efforts.length > 0
61
+ && !inventory.efforts.includes(effort);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * How `agent run` stops on a signal while supervised process groups may be
3
+ * live (see @commonlyai/seats/process-group).
4
+ *
5
+ * SIGTERM keeps the in-flight turn: the run loop stops polling and lets the
6
+ * turn finish. A launchd seat is SIGKILLed ExitTimeOut seconds after SIGTERM,
7
+ * and that SIGKILL cannot be caught, so a supervised group (outside the seat's
8
+ * own group, which launchd kills) would outlive the seat. So on a launchd
9
+ * seat, SIGTERM also arms a deadline a margin before ExitTimeOut, and the exit
10
+ * path kills whatever groups are left when the turn ends first.
11
+ *
12
+ * The deadline is armed only when the seat's plist exists. A seat without one
13
+ * (the daemon spawns it) is stopped with SIGTERM and never SIGKILLed, so it
14
+ * relies on the exit path alone, and a reassignment or config change does not
15
+ * cut the in-flight turn.
16
+ *
17
+ * ExitTimeOut is read from the seat's plist and never written. In a plist that
18
+ * exists, an unset, zero or unreadable value is launchd's default of 20 s,
19
+ * because launchd will SIGKILL that seat.
20
+ */
21
+ import { spawnSync } from 'child_process';
22
+ import { existsSync, readFileSync } from 'fs';
23
+ import { homedir } from 'os';
24
+ import { join } from 'path';
25
+ import { armSupervisedGroupDeadline, killSupervisedGroups } from '@commonlyai/seats/process-group';
26
+
27
+ export const DEFAULT_EXIT_TIMEOUT_S = 20;
28
+ const MAX_MARGIN_MS = 5000;
29
+ // `@` joins an agent and its instance in a second instance's key (F306).
30
+ const SEAT_NAME = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/;
31
+
32
+ /** The seat's launchd plist path, or null for a name that cannot be a file name. */
33
+ export const seatPlistPath = (name, home = homedir()) => (typeof name === 'string' && SEAT_NAME.test(name)
34
+ ? join(home, 'Library', 'LaunchAgents', `me.commonly.seat-${name}.plist`)
35
+ : null);
36
+
37
+ const positive = (value) => (Number.isFinite(value) && value > 0 ? value : null);
38
+
39
+ /**
40
+ * ExitTimeOut in seconds, or null when the plist is missing, has none, or
41
+ * holds a value launchd would not use as a timeout. An XML plist is read
42
+ * directly; a binary one through `plutil -extract` (read-only).
43
+ */
44
+ export const readExitTimeOut = (path, { readFile = readFileSync, run = spawnSync } = {}) => {
45
+ if (!path) return null;
46
+ let raw;
47
+ try { raw = readFile(path); } catch { return null; }
48
+ const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw);
49
+ if (text.startsWith('bplist')) {
50
+ try {
51
+ const out = run('plutil', ['-extract', 'ExitTimeOut', 'raw', '-o', '-', path], { encoding: 'utf8', timeout: 5000 });
52
+ return out.status === 0 ? positive(Number(String(out.stdout).trim())) : null;
53
+ } catch { return null; }
54
+ }
55
+ const match = /<key>\s*ExitTimeOut\s*<\/key>\s*<(integer|real)>\s*([0-9.]+)\s*<\/\1>/.exec(text);
56
+ return match ? positive(Number(match[2])) : null;
57
+ };
58
+
59
+ /**
60
+ * Whether the seat's plist exists, and its ExitTimeOut (null when unset or
61
+ * unreadable). Only an existing plist means launchd will SIGKILL this seat.
62
+ */
63
+ export const readSeatPlist = (path, { exists = existsSync, ...deps } = {}) => {
64
+ if (!path || !exists(path)) return { present: false, exitTimeOut: null };
65
+ return { present: true, exitTimeOut: readExitTimeOut(path, deps) };
66
+ };
67
+
68
+ /** Milliseconds after SIGTERM to kill the groups: ExitTimeOut less a margin of 5 s or a quarter, whichever is less. */
69
+ export const groupKillDeadlineMs = (exitTimeOutS) => {
70
+ const totalMs = (positive(exitTimeOutS) ?? DEFAULT_EXIT_TIMEOUT_S) * 1000;
71
+ return Math.floor(totalMs - Math.min(MAX_MARGIN_MS, totalMs / 4));
72
+ };
73
+
74
+ /**
75
+ * `requestStop(signal)` stops the run loop and, on SIGTERM to a seat with a
76
+ * plist, arms the group deadline; `onExit` kills the groups left at exit.
77
+ * `deps` is a test seam.
78
+ */
79
+ export const createSeatStop = ({ name, stop, home, deps = {} } = {}) => {
80
+ const {
81
+ arm = armSupervisedGroupDeadline,
82
+ kill = killSupervisedGroups,
83
+ readPlist = readSeatPlist,
84
+ } = deps;
85
+ return {
86
+ requestStop(signal) {
87
+ stop();
88
+ if (signal !== 'SIGTERM') return null;
89
+ const plist = readPlist(seatPlistPath(name, home));
90
+ if (!plist.present) return null;
91
+ const deadlineMs = groupKillDeadlineMs(plist.exitTimeOut);
92
+ arm(deadlineMs);
93
+ return deadlineMs;
94
+ },
95
+ onExit() { kill(); },
96
+ };
97
+ };
@@ -0,0 +1,32 @@
1
+ // Agent-scoped catalog skills arrive from Commonly at each turn start. The
2
+ // wrapper gives their instructions to either adapter as prompt text, so a
3
+ // browser add/remove changes the next spawn without touching local files.
4
+ const MAX_BYTES = 64 * 1024;
5
+ const PREAMBLE = 'Installed skills for this agent and pod. Follow the applicable instructions below.';
6
+ const FRONTMATTER = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n?/;
7
+
8
+ export const compileServerSkills = (items = []) => {
9
+ const sections = [];
10
+ const mounted = [];
11
+ const conflicted = [];
12
+ let bytes = Buffer.byteLength(PREAMBLE);
13
+ for (const item of items) {
14
+ const name = String(item?.name || '').replace(/[\r\n]/g, ' ').trim();
15
+ const content = String(item?.content || '').replace(FRONTMATTER, '').trim();
16
+ if (!name || !content) continue;
17
+ const section = `## Skill: ${name}\n\n${content}`;
18
+ const nextBytes = Buffer.byteLength(section) + 2;
19
+ if (bytes + nextBytes > MAX_BYTES) {
20
+ conflicted.push(name);
21
+ continue;
22
+ }
23
+ sections.push(section);
24
+ mounted.push(name);
25
+ bytes += nextBytes;
26
+ }
27
+ return {
28
+ text: sections.length ? `${PREAMBLE}\n\n${sections.join('\n\n')}` : null,
29
+ mounted,
30
+ conflicted,
31
+ };
32
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * At-wake session recycle (TASK-440).
3
+ *
4
+ * A seat resumes its CLI session on every wake, so a long-lived seat drags a
5
+ * large cold context into each turn. When a wake finds the seat idle long
6
+ * enough that the prompt cache is cold AND the last session's context is big
7
+ * enough to matter, start a fresh session instead, seeded with a short
8
+ * restart note. Otherwise resume exactly as before.
9
+ *
10
+ * F392: a seat woken every few minutes is never idle long enough for that
11
+ * rule, so its context grew without bound. Past a hard ceiling it starts fresh
12
+ * whatever the idle time.
13
+ *
14
+ * Thresholds are per-install: COMMONLY_SESSION_RECYCLE_IDLE_MINUTES,
15
+ * COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS and
16
+ * COMMONLY_SESSION_RECYCLE_MAX_CONTEXT_TOKENS override the defaults below.
17
+ *
18
+ * Context size is what the last model request of the turn was sent: claude and
19
+ * codex from the transcript the child CLI wrote, under the env it ran with; pi
20
+ * and dsh from the usage on their own JSON stream (F404). An adapter that
21
+ * reports nothing keeps resuming, as before this module existed.
22
+ */
23
+
24
+ import {
25
+ closeSync, existsSync, fstatSync, openSync, readdirSync, readSync,
26
+ } from 'fs';
27
+ import { join } from 'path';
28
+ import { homedir } from 'os';
29
+
30
+ export const RECYCLE_DEFAULTS = Object.freeze({
31
+ idleMinutes: 60,
32
+ contextTokens: 128000,
33
+ maxContextTokens: 256000,
34
+ });
35
+
36
+ // Read this many bytes from the end of a transcript. The last assistant
37
+ // usage record sits near the end; a full read of a long-lived transcript
38
+ // would cost more than the recycle saves.
39
+ const TRANSCRIPT_TAIL_BYTES = 4 * 1024 * 1024;
40
+
41
+ const positiveNumber = (raw, fallback) => {
42
+ const value = Number(raw);
43
+ return Number.isFinite(value) && value > 0 ? value : fallback;
44
+ };
45
+
46
+ export const recycleThresholds = (env = process.env) => ({
47
+ idleMinutes: positiveNumber(env.COMMONLY_SESSION_RECYCLE_IDLE_MINUTES, RECYCLE_DEFAULTS.idleMinutes),
48
+ contextTokens: positiveNumber(env.COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS, RECYCLE_DEFAULTS.contextTokens),
49
+ maxContextTokens: positiveNumber(env.COMMONLY_SESSION_RECYCLE_MAX_CONTEXT_TOKENS, RECYCLE_DEFAULTS.maxContextTokens),
50
+ });
51
+
52
+ // Pure decision: 'cold' (idle long enough AND large), 'size' (past the hard
53
+ // ceiling, however recent), or null to resume. No evidence (no last turn, no
54
+ // measured context) means resume.
55
+ export const recycleReason = ({
56
+ lastTurn,
57
+ contextTokens,
58
+ now = Date.now(),
59
+ thresholds = RECYCLE_DEFAULTS,
60
+ }) => {
61
+ if (!lastTurn || !Number.isFinite(contextTokens)) return null;
62
+ const last = Date.parse(lastTurn);
63
+ if (!Number.isFinite(last)) return null;
64
+ if (now - last >= thresholds.idleMinutes * 60_000 && contextTokens >= thresholds.contextTokens) return 'cold';
65
+ const ceiling = thresholds.maxContextTokens ?? RECYCLE_DEFAULTS.maxContextTokens;
66
+ return contextTokens >= ceiling ? 'size' : null;
67
+ };
68
+
69
+ export const shouldStartFreshSession = (args) => recycleReason(args) !== null;
70
+
71
+ export const idleMinutesSince = (lastTurn, now = Date.now()) => {
72
+ const last = Date.parse(lastTurn);
73
+ return Number.isFinite(last) ? Math.floor((now - last) / 60_000) : null;
74
+ };
75
+
76
+ // The restart note. The memory head and the pod focus are NOT repeated here:
77
+ // a fresh claude session already receives the full long_term memory through
78
+ // its preamble, and the pod focus is already in the wake prompt.
79
+ export const buildRecycleBriefing = ({
80
+ agentName, reason = 'cold', idleMinutes, contextTokens, maxContextTokens = RECYCLE_DEFAULTS.maxContextTokens,
81
+ }) => [
82
+ '=== Session restart ===',
83
+ reason === 'size'
84
+ ? `This wake starts a fresh session. The previous session had grown to about ${contextTokens}`
85
+ + ` context tokens, past the ${maxContextTokens}-token ceiling, so it was not resumed.`
86
+ : `This wake starts a fresh session. The previous session was idle ${idleMinutes} min`
87
+ + ` with about ${contextTokens} context tokens, so it was not resumed.`,
88
+ `Role: ${agentName}. Your saved memory and the pod focus follow below.`,
89
+ ].join('\n');
90
+
91
+ // The config dir the CHILD claude process uses. A public seat runs claude
92
+ // under an isolated HOME, so reading the operator's ~/.claude would find no
93
+ // transcript and the recycle would never fire.
94
+ export const claudeConfigDirFor = (env = process.env) => (
95
+ env?.CLAUDE_CONFIG_DIR || join(env?.HOME || homedir(), '.claude')
96
+ );
97
+
98
+ // Claude Code stores a session at
99
+ // <config>/projects/<cwd with every non-alphanumeric character as '-'>/<id>.jsonl
100
+ export const claudeTranscriptPath = ({ cwd, sessionId, configDir = claudeConfigDirFor() }) => {
101
+ if (!cwd || !sessionId) return null;
102
+ const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-');
103
+ return join(configDir, 'projects', encoded, `${sessionId}.jsonl`);
104
+ };
105
+
106
+ const readTail = (file) => {
107
+ const fd = openSync(file, 'r');
108
+ try {
109
+ const size = fstatSync(fd).size;
110
+ const length = Math.min(size, TRANSCRIPT_TAIL_BYTES);
111
+ const buffer = Buffer.alloc(length);
112
+ readSync(fd, buffer, 0, length, size - length);
113
+ return buffer.toString('utf8');
114
+ } finally {
115
+ closeSync(fd);
116
+ }
117
+ };
118
+
119
+ // Context tokens of the most recent assistant turn: uncached input plus the
120
+ // cache creation and cache read that make up the prompt sent that turn.
121
+ export const contextTokensFromTranscript = (text) => {
122
+ const lines = text.split('\n');
123
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
124
+ const line = lines[index].trim();
125
+ if (!line) continue;
126
+ let record;
127
+ try {
128
+ record = JSON.parse(line);
129
+ } catch {
130
+ continue; // the first line of a tail read can be cut mid-record
131
+ }
132
+ const usage = record?.message?.usage;
133
+ if (record?.type === 'assistant' && usage && typeof usage === 'object') {
134
+ return (usage.input_tokens || 0)
135
+ + (usage.cache_creation_input_tokens || 0)
136
+ + (usage.cache_read_input_tokens || 0);
137
+ }
138
+ }
139
+ return null;
140
+ };
141
+
142
+ /**
143
+ * The prompt one request was sent, from a usage block that counts cached tokens
144
+ * apart from `input` (pi's message usage, dsh's step usage). Null when `input`
145
+ * is missing, so a stream without usage reads as unknown, not as zero.
146
+ */
147
+ export const promptTokens = ({ input, cacheRead, cacheWrite } = {}) => {
148
+ if (!Number.isFinite(input)) return null;
149
+ return input + (Number.isFinite(cacheRead) ? cacheRead : 0) + (Number.isFinite(cacheWrite) ? cacheWrite : 0);
150
+ };
151
+
152
+ // F404. codex keeps a session as <home>/sessions/YYYY/MM/DD/rollout-<time>-<id>.jsonl,
153
+ // dated by when it began; `exec resume` appends to that same file. Newest day
154
+ // first, so a live session is found after a few directory reads.
155
+ const newestFirst = (dir) => {
156
+ try {
157
+ return readdirSync(dir).sort().reverse();
158
+ } catch {
159
+ return [];
160
+ }
161
+ };
162
+
163
+ export const codexHomeFor = (env = process.env) => env?.CODEX_HOME || join(env?.HOME || homedir(), '.codex');
164
+
165
+ export const codexRolloutPath = ({ codexHome = codexHomeFor(), sessionId }) => {
166
+ if (!sessionId) return null;
167
+ const root = join(codexHome, 'sessions');
168
+ const suffix = `-${sessionId}.jsonl`;
169
+ for (const year of newestFirst(root)) {
170
+ for (const month of newestFirst(join(root, year))) {
171
+ for (const day of newestFirst(join(root, year, month))) {
172
+ const name = newestFirst(join(root, year, month, day)).find((file) => file.endsWith(suffix));
173
+ if (name) return join(root, year, month, day, name);
174
+ }
175
+ }
176
+ }
177
+ return null;
178
+ };
179
+
180
+ // The last request's prompt from codex's own `token_count` record. Its
181
+ // `input_tokens` already includes the cached part (`cached_input_tokens` is a
182
+ // subset), and `last_token_usage` is one request, where `total_token_usage` and
183
+ // the stream's `turn.completed` sum every request in the turn.
184
+ export const contextTokensFromCodexRollout = (text) => {
185
+ const lines = text.split('\n');
186
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
187
+ const line = lines[index].trim();
188
+ if (!line || !line.includes('token_count')) continue;
189
+ let record;
190
+ try {
191
+ record = JSON.parse(line);
192
+ } catch {
193
+ continue;
194
+ }
195
+ const input = record?.payload?.type === 'token_count'
196
+ ? record.payload.info?.last_token_usage?.input_tokens : undefined;
197
+ if (Number.isFinite(input)) return input;
198
+ }
199
+ return null;
200
+ };
201
+
202
+ export const codexContextTokens = ({ codexHome = codexHomeFor(), sessionId }) => {
203
+ try {
204
+ const file = codexRolloutPath({ codexHome, sessionId });
205
+ return file ? contextTokensFromCodexRollout(readTail(file)) : null;
206
+ } catch {
207
+ return null;
208
+ }
209
+ };
210
+
211
+ export const claudeContextTokens = ({ cwd, sessionId, configDir = claudeConfigDirFor() }) => {
212
+ const file = claudeTranscriptPath({ cwd, sessionId, configDir });
213
+ if (!file || !existsSync(file)) return null;
214
+ try {
215
+ return contextTokensFromTranscript(readTail(file));
216
+ } catch {
217
+ return null;
218
+ }
219
+ };
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Session store — per-(agent, pod) session IDs at ~/.commonly/sessions/<agent>.json.
3
+ *
4
+ * Used by the local-CLI wrapper driver (ADR-005). Wrapped CLIs that support
5
+ * conversation session IDs (claude --session-id, codex --session, …) call
6
+ * getSession / setSession around each spawn so the next turn continues where
7
+ * the previous one left off.
8
+ *
9
+ * One file per agent so two `commonly agent run` processes for different
10
+ * agents never race on the same file (ADR-005 §Spawning semantics permits
11
+ * parallel agents). Shape on disk:
12
+ * {
13
+ * "<podId>": { "sessionId": "abc123", "lastTurn": "2026-04-14T18:00:00Z" }
14
+ * }
15
+ *
16
+ * CLIs without sessions simply never call setSession — getSession returns null.
17
+ *
18
+ * Handled runtime-event IDs are intentionally persisted in a separate
19
+ * `<agent>.events.json` file. Keeping the bounded event ring separate means
20
+ * session-map readers retain their existing on-disk shape while a wrapper
21
+ * restart can still suppress a re-delivered event whose ack previously failed.
22
+ */
23
+
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, renameSync, chmodSync } from 'fs';
25
+ import { join } from 'path';
26
+ import { homedir } from 'os';
27
+
28
+ const sessionsDir = () => join(homedir(), '.commonly', 'sessions');
29
+ const sessionsFile = (agentName) => join(sessionsDir(), `${agentName}.json`);
30
+ const handledEventsFile = (agentName) => join(sessionsDir(), `${agentName}.events.json`);
31
+ const noticesFile = (agentName) => join(sessionsDir(), `${agentName}.notices.json`);
32
+ const MAX_HANDLED_EVENTS = 500;
33
+ const handledEventsCache = new Map();
34
+
35
+ // Everything under ~/.commonly is private (D275). These files hold per-(agent,
36
+ // pod) session IDs and event rings; keep the dir and its files owner-only, and
37
+ // tighten an existing dir or file that a pre-0.1.309 CLI left group/world-
38
+ // readable. A mode arg applies only when the path is created, so each write
39
+ // chmods after — the same belt-and-braces config.js uses for the token file.
40
+ const ensureSessionsDir = () => {
41
+ const dir = sessionsDir();
42
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
43
+ try { chmodSync(dir, 0o700); } catch { /* a dir we don't own: leave it */ }
44
+ return dir;
45
+ };
46
+
47
+ const writeSessionFile = (file, data) => {
48
+ writeFileSync(file, data, { encoding: 'utf8', mode: 0o600 });
49
+ try { chmodSync(file, 0o600); } catch { /* best effort to tighten an existing file */ }
50
+ };
51
+
52
+ const readAgent = (agentName) => {
53
+ const file = sessionsFile(agentName);
54
+ if (!existsSync(file)) return {};
55
+ try {
56
+ return JSON.parse(readFileSync(file, 'utf8'));
57
+ } catch {
58
+ return {};
59
+ }
60
+ };
61
+
62
+ const writeAgent = (agentName, state) => {
63
+ ensureSessionsDir();
64
+ writeSessionFile(sessionsFile(agentName), JSON.stringify(state, null, 2));
65
+ };
66
+
67
+ const readHandledEvents = (agentName) => {
68
+ const file = handledEventsFile(agentName);
69
+ if (!existsSync(file)) return [];
70
+ try {
71
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
72
+ if (!Array.isArray(parsed)) return [];
73
+ return parsed
74
+ .filter((eventId) => typeof eventId === 'string' && eventId.length > 0)
75
+ .slice(-MAX_HANDLED_EVENTS);
76
+ } catch {
77
+ return [];
78
+ }
79
+ };
80
+
81
+ const getHandledEvents = (agentName) => {
82
+ const file = handledEventsFile(agentName);
83
+ // Tests, detach, or an operator may remove the file while this process is
84
+ // alive. Reset the mirror rather than retaining IDs that no longer exist on
85
+ // disk; a fresh process likewise hydrates from the persisted ring.
86
+ if (!existsSync(file)) {
87
+ const empty = { ids: [], set: new Set() };
88
+ handledEventsCache.set(agentName, empty);
89
+ return empty;
90
+ }
91
+ if (!handledEventsCache.has(agentName)) {
92
+ const ids = readHandledEvents(agentName);
93
+ handledEventsCache.set(agentName, { ids, set: new Set(ids) });
94
+ }
95
+ return handledEventsCache.get(agentName);
96
+ };
97
+
98
+ const writeHandledEvents = (agentName, ids) => {
99
+ ensureSessionsDir();
100
+ writeSessionFile(handledEventsFile(agentName), JSON.stringify(ids, null, 2));
101
+ };
102
+
103
+ export const getSession = (agentName, podId, { binding = null } = {}) => {
104
+ if (!agentName || !podId) return null;
105
+ const entry = readAgent(agentName)[podId];
106
+ if (binding !== null && entry?.binding !== binding) return null;
107
+ return entry?.sessionId || null;
108
+ };
109
+
110
+ // The whole per-pod entry (sessionId, lastTurn, contextTokens), for the
111
+ // at-wake recycle decision. getSession stays the resume-only reader.
112
+ export const getSessionEntry = (agentName, podId, { binding = null } = {}) => {
113
+ if (!agentName || !podId) return null;
114
+ const entry = readAgent(agentName)[podId];
115
+ if (!entry || (binding !== null && entry.binding !== binding)) return null;
116
+ return entry;
117
+ };
118
+
119
+ // Read-only diagnostic projection used by the daemon operator surface. A
120
+ // wrapper may serve several pods, so return the newest completed turn.
121
+ export const getLastTurn = (agentName) => {
122
+ if (!agentName) return null;
123
+ const state = readAgent(agentName);
124
+ return Object.values(state)
125
+ .map((entry) => entry?.lastTurn)
126
+ .filter((value) => typeof value === 'string')
127
+ .sort()
128
+ .at(-1) || null;
129
+ };
130
+
131
+ export const setSession = (agentName, podId, sessionId, { binding = null, contextTokens = null } = {}) => {
132
+ if (!agentName || !podId) return;
133
+ const state = readAgent(agentName);
134
+ state[podId] = {
135
+ sessionId,
136
+ lastTurn: new Date().toISOString(),
137
+ ...(binding !== null ? { binding } : {}),
138
+ ...(Number.isFinite(contextTokens) ? { contextTokens } : {}),
139
+ };
140
+ writeAgent(agentName, state);
141
+ };
142
+
143
+ // One pod's session, for a thread that can't be resumed any more.
144
+ export const clearSession = (agentName, podId) => {
145
+ if (!agentName || !podId) return;
146
+ const state = readAgent(agentName);
147
+ if (!(podId in state)) return;
148
+ delete state[podId];
149
+ writeAgent(agentName, state);
150
+ };
151
+
152
+ export const wasEventHandled = (agentName, eventId) => {
153
+ if (!agentName || !eventId) return false;
154
+ return getHandledEvents(agentName).set.has(String(eventId));
155
+ };
156
+
157
+ export const recordHandledEvent = (agentName, eventId) => {
158
+ if (!agentName || !eventId) return;
159
+ const normalizedId = String(eventId);
160
+ const current = getHandledEvents(agentName);
161
+ if (current.set.has(normalizedId)) return;
162
+
163
+ const ids = [...current.ids, normalizedId].slice(-MAX_HANDLED_EVENTS);
164
+ writeHandledEvents(agentName, ids);
165
+ handledEventsCache.set(agentName, { ids, set: new Set(ids) });
166
+ };
167
+
168
+ // F178: when the wrapper last posted a rate-limited pod notice, by key, so a
169
+ // notice is not repeated every turn and a restarted seat remembers it.
170
+ // Shape on disk: { "<key>": "<ISO time>" }.
171
+ const readNotices = (agentName) => {
172
+ const file = noticesFile(agentName);
173
+ if (!existsSync(file)) return {};
174
+ try {
175
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
176
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
177
+ } catch {
178
+ return {};
179
+ }
180
+ };
181
+
182
+ /** True when no notice under `key` was recorded within the last `windowMs`. */
183
+ export const noticeDue = (agentName, key, windowMs, now = Date.now()) => {
184
+ if (!agentName || !key) return false;
185
+ const last = Date.parse(readNotices(agentName)[key]);
186
+ return !Number.isFinite(last) || now - last >= windowMs || last > now;
187
+ };
188
+
189
+ export const recordNotice = (agentName, key, now = Date.now()) => {
190
+ if (!agentName || !key) return;
191
+ const state = readNotices(agentName);
192
+ state[key] = new Date(now).toISOString();
193
+ ensureSessionsDir();
194
+ // Temp file then rename, so a crash mid-write never leaves a torn file, and
195
+ // 0600 because the record says which models this seat's key cannot use.
196
+ const file = noticesFile(agentName);
197
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
198
+ try {
199
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
200
+ renameSync(tmp, file);
201
+ } catch (error) {
202
+ rmSync(tmp, { force: true });
203
+ throw error;
204
+ }
205
+ };
206
+
207
+ export const clearSessions = (agentName) => {
208
+ const file = sessionsFile(agentName);
209
+ if (existsSync(file)) rmSync(file);
210
+ const eventsFile = handledEventsFile(agentName);
211
+ if (existsSync(eventsFile)) rmSync(eventsFile);
212
+ const notices = noticesFile(agentName);
213
+ if (existsSync(notices)) rmSync(notices);
214
+ handledEventsCache.delete(agentName);
215
+ };