@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.
- package/LICENSE +205 -0
- package/NOTICE +55 -0
- package/README.md +63 -2
- package/assets/webhook-sdk/python/bot.py +63 -0
- package/assets/webhook-sdk/python/commonly.py +232 -0
- package/node_modules/@commonlyai/seats/README.md +25 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +44 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
- package/package.json +63 -4
- package/skills/commonly/SKILL.md +233 -0
- package/src/commands/agent.js +6334 -0
- package/src/commands/daemon.js +933 -0
- package/src/commands/dev.js +153 -0
- package/src/commands/doctor.js +141 -0
- package/src/commands/inbox.js +330 -0
- package/src/commands/login.js +194 -0
- package/src/commands/mcp.js +56 -0
- package/src/commands/pod.js +120 -0
- package/src/commands/task.js +279 -0
- package/src/commands/verify.js +48 -0
- package/src/index.js +99 -0
- package/src/lib/adapter-login.js +76 -0
- package/src/lib/adapters/broker-bridge-entry.js +76 -0
- package/src/lib/adapters/claude.js +2207 -0
- package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
- package/src/lib/adapters/codex.js +1898 -0
- package/src/lib/adapters/dsh.js +513 -0
- package/src/lib/adapters/goose.js +510 -0
- package/src/lib/adapters/hermes.js +18 -0
- package/src/lib/adapters/index.js +68 -0
- package/src/lib/adapters/opencode.js +1681 -0
- package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
- package/src/lib/adapters/pi-mcp-client.mjs +617 -0
- package/src/lib/adapters/pi-shell-env.mjs +41 -0
- package/src/lib/adapters/pi.js +1025 -0
- package/src/lib/adapters/qwen.js +523 -0
- package/src/lib/adapters/seat-model.js +8 -0
- package/src/lib/adapters/stub.js +1 -0
- package/src/lib/adapters/tested-version.js +106 -0
- package/src/lib/api.js +205 -0
- package/src/lib/claim-outcome.js +128 -0
- package/src/lib/claimed-work-gate.js +73 -0
- package/src/lib/cli-hint.js +33 -0
- package/src/lib/commit-attribution.js +391 -0
- package/src/lib/config.js +155 -0
- package/src/lib/content-refusal.js +94 -0
- package/src/lib/credential-file.js +208 -0
- package/src/lib/daemon-agent-states.js +95 -0
- package/src/lib/daemon-hint.js +18 -0
- package/src/lib/daemon-logs.js +43 -0
- package/src/lib/daemon-service-windows.js +263 -0
- package/src/lib/daemon-service.js +486 -0
- package/src/lib/daemon-state.js +84 -0
- package/src/lib/daemon-store.js +119 -0
- package/src/lib/daemon-supervisor.js +629 -0
- package/src/lib/daemon-watch.js +115 -0
- package/src/lib/declared-mcp-guard.js +284 -0
- package/src/lib/default-environment.js +207 -0
- package/src/lib/device-login.js +126 -0
- package/src/lib/digest-frame.js +151 -0
- package/src/lib/doctor.js +291 -0
- package/src/lib/egress-proxy.js +160 -0
- package/src/lib/enforcement.js +946 -0
- package/src/lib/environment.js +681 -0
- package/src/lib/first-output-watchdog.js +191 -0
- package/src/lib/git-readonly-server.js +138 -0
- package/src/lib/git-readonly.js +463 -0
- package/src/lib/git-remote-commonly.mjs +100 -0
- package/src/lib/grant-broker-guard.js +345 -0
- package/src/lib/hooks-config.js +238 -0
- package/src/lib/host-gh-broker.js +152 -0
- package/src/lib/host-gh-shim.mjs +98 -0
- package/src/lib/inbox.js +483 -0
- package/src/lib/instance-proxy.js +76 -0
- package/src/lib/machine-host.js +29 -0
- package/src/lib/mcp-credential-delivery.js +121 -0
- package/src/lib/mcp-first-run.js +50 -0
- package/src/lib/mcp-home.js +644 -0
- package/src/lib/mcp-ready.js +68 -0
- package/src/lib/mcp-server-version.js +123 -0
- package/src/lib/mcp-token.js +36 -0
- package/src/lib/mcp-warm-child.mjs +412 -0
- package/src/lib/memory-bridge.js +191 -0
- package/src/lib/memory-import.js +156 -0
- package/src/lib/missing-harness.js +31 -0
- package/src/lib/model-unavailable.js +154 -0
- package/src/lib/native-runtime-inventory.js +40 -0
- package/src/lib/outsider-grant-mcp.js +82 -0
- package/src/lib/own-key-launch.js +42 -0
- package/src/lib/pod-catch-up.js +55 -0
- package/src/lib/pod-focus.js +224 -0
- package/src/lib/pod-tail.js +68 -0
- package/src/lib/poll-liveness.js +103 -0
- package/src/lib/poll-retry.js +81 -0
- package/src/lib/poller.js +200 -0
- package/src/lib/provider-proxy.js +42 -0
- package/src/lib/public-seat-mcp-flag.js +36 -0
- package/src/lib/public-tool-error.js +60 -0
- package/src/lib/public-withheld-tools.js +48 -0
- package/src/lib/reply-receipt.js +55 -0
- package/src/lib/resolve-on-path.js +22 -0
- package/src/lib/runtime-inventory.js +56 -0
- package/src/lib/runtime-observation.js +37 -0
- package/src/lib/sandbox/bwrap.js +275 -0
- package/src/lib/sandbox/codex-perimeter.js +16 -0
- package/src/lib/sandbox/host-check.js +268 -0
- package/src/lib/sandbox/mode.js +49 -0
- package/src/lib/sandbox/opencode-loopback.mjs +68 -0
- package/src/lib/sandbox/seatbelt.js +684 -0
- package/src/lib/sandbox/turn-dir.js +56 -0
- package/src/lib/sandbox/windows-unsandboxed.js +183 -0
- package/src/lib/sandbox/workspace-git-config.js +243 -0
- package/src/lib/sandbox/workspace-pin.js +158 -0
- package/src/lib/seat-runtime-controls.js +61 -0
- package/src/lib/seat-stop.js +97 -0
- package/src/lib/server-skills.js +32 -0
- package/src/lib/session-recycle.js +219 -0
- package/src/lib/session-store.js +215 -0
- package/src/lib/skill-prompt.js +74 -0
- package/src/lib/skills-import.js +113 -0
- package/src/lib/spawn-credential.js +402 -0
- package/src/lib/spawn-retry.js +311 -0
- package/src/lib/stable-install.js +176 -0
- package/src/lib/supervised-turn.js +352 -0
- package/src/lib/task.js +235 -0
- package/src/lib/turn-failure-notice.js +177 -0
- package/src/lib/unconfined-seat-guard.js +94 -0
- package/src/lib/update-check.js +192 -0
- package/src/lib/upstream-refusal.js +1 -0
- package/src/lib/webhook-server.js +109 -0
- package/src/lib/windows-command.js +393 -0
- package/src/lib/workspace-git.js +87 -0
- 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
|
+
};
|