@superjackfruitlabs/salaam 0.0.3 → 0.0.5
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/CHANGELOG.md +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
4
|
+
import { canonical, digest, textDigest } from './protocol.js';
|
|
5
|
+
import type { Artifacts } from './artifacts.js';
|
|
6
|
+
import type { Store } from './store.js';
|
|
7
|
+
|
|
8
|
+
/** One file's bound, and the bound on everything loaded together. */
|
|
9
|
+
export const INSTRUCTION_FILE_BYTES = 32 * 1024;
|
|
10
|
+
export const INSTRUCTION_TOTAL_BYTES = 64 * 1024;
|
|
11
|
+
/** AGENTS.md is the shared convention; CLAUDE.md is read only where a directory has no AGENTS.md. */
|
|
12
|
+
const NAMES = ['AGENTS.md', 'CLAUDE.md'];
|
|
13
|
+
|
|
14
|
+
export interface InstructionFile {
|
|
15
|
+
scope: 'user' | 'project';
|
|
16
|
+
/** Shown to people: relative to the session folder, or ~/… for the user file. */
|
|
17
|
+
path: string;
|
|
18
|
+
sha256: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
text: string;
|
|
21
|
+
}
|
|
22
|
+
export interface InstructionSkip {
|
|
23
|
+
path: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
}
|
|
26
|
+
export interface InstructionsSummary {
|
|
27
|
+
digest: string;
|
|
28
|
+
revision: number;
|
|
29
|
+
files: Omit<InstructionFile, 'text'>[];
|
|
30
|
+
skipped: InstructionSkip[];
|
|
31
|
+
}
|
|
32
|
+
interface InstructionsDocument {
|
|
33
|
+
version: 1;
|
|
34
|
+
files: InstructionFile[];
|
|
35
|
+
skipped: InstructionSkip[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The user-level instructions file: $XDG_CONFIG_HOME/salaam/AGENTS.md, else ~/.config/salaam/AGENTS.md. */
|
|
39
|
+
export function userInstructionsPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
40
|
+
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'salaam', 'AGENTS.md');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function repositoryRoot(cwd: string): string | null {
|
|
44
|
+
let dir = cwd;
|
|
45
|
+
for (let i = 0; i < 64; i++) {
|
|
46
|
+
if (existsSync(join(dir, '.git'))) return dir;
|
|
47
|
+
const parent = dirname(dir);
|
|
48
|
+
if (parent === dir) return null;
|
|
49
|
+
dir = parent;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Project instructions for an interactive session, the way other harnesses read them: the
|
|
56
|
+
* user-level file, then AGENTS.md (or CLAUDE.md) in every directory from the repository root down
|
|
57
|
+
* to the session folder. Bounded, regular UTF-8 files only; anything skipped says why.
|
|
58
|
+
*/
|
|
59
|
+
export function discoverInstructions(
|
|
60
|
+
cwd: string,
|
|
61
|
+
userFile: string | null = userInstructionsPath(),
|
|
62
|
+
): { files: InstructionFile[]; skipped: InstructionSkip[] } {
|
|
63
|
+
const files: InstructionFile[] = [];
|
|
64
|
+
const skipped: InstructionSkip[] = [];
|
|
65
|
+
let total = 0;
|
|
66
|
+
/** Returns whether the file exists (so CLAUDE.md is not read beside an AGENTS.md). */
|
|
67
|
+
const take = (scope: InstructionFile['scope'], abs: string, shown: string): boolean => {
|
|
68
|
+
let st;
|
|
69
|
+
try {
|
|
70
|
+
st = lstatSync(abs);
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (!st.isFile()) {
|
|
75
|
+
skipped.push({
|
|
76
|
+
path: shown,
|
|
77
|
+
reason: st.isSymbolicLink() ? 'symbolic links are not loaded' : 'not a regular file',
|
|
78
|
+
});
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
if (st.size > INSTRUCTION_FILE_BYTES) {
|
|
82
|
+
skipped.push({ path: shown, reason: `larger than ${INSTRUCTION_FILE_BYTES / 1024} KiB` });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const bytes = readFileSync(abs);
|
|
86
|
+
const text = bytes.toString('utf8');
|
|
87
|
+
if (!Buffer.from(text).equals(bytes) || text.includes('\0')) {
|
|
88
|
+
skipped.push({ path: shown, reason: 'not UTF-8 text' });
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (total + bytes.length > INSTRUCTION_TOTAL_BYTES) {
|
|
92
|
+
skipped.push({
|
|
93
|
+
path: shown,
|
|
94
|
+
reason: `instructions together exceed ${INSTRUCTION_TOTAL_BYTES / 1024} KiB`,
|
|
95
|
+
});
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
total += bytes.length;
|
|
99
|
+
files.push({ scope, path: shown, sha256: textDigest(text), bytes: bytes.length, text });
|
|
100
|
+
return true;
|
|
101
|
+
};
|
|
102
|
+
if (userFile) {
|
|
103
|
+
const home = homedir();
|
|
104
|
+
take(
|
|
105
|
+
'user',
|
|
106
|
+
userFile,
|
|
107
|
+
userFile.startsWith(home + sep) ? '~/' + relative(home, userFile) : userFile,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const root = repositoryRoot(cwd) ?? cwd;
|
|
111
|
+
const dirs = [root];
|
|
112
|
+
for (const part of relative(root, cwd).split(sep).filter(Boolean))
|
|
113
|
+
dirs.push(join(dirs.at(-1)!, part));
|
|
114
|
+
for (const dir of dirs)
|
|
115
|
+
for (const name of NAMES) {
|
|
116
|
+
const abs = join(dir, name);
|
|
117
|
+
if (take('project', abs, relative(cwd, abs) || name)) break;
|
|
118
|
+
}
|
|
119
|
+
return { files, skipped };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Interactive sessions load project instructions automatically and reload them when they change.
|
|
124
|
+
* Each loaded set is kept as an artifact and announced by an `instructions.loaded` event with its
|
|
125
|
+
* digest, so a turn's guidance is traceable to exact bytes. Strict work (board, AgentPod, child
|
|
126
|
+
* workers) never uses this: it keeps explicitly reviewed context only.
|
|
127
|
+
*/
|
|
128
|
+
export class ProjectInstructions {
|
|
129
|
+
private readonly loaded = new Map<string, { digest: string; document: InstructionsDocument }>();
|
|
130
|
+
constructor(
|
|
131
|
+
readonly store: Store,
|
|
132
|
+
readonly artifacts: Artifacts,
|
|
133
|
+
readonly userFile: string | null = userInstructionsPath(),
|
|
134
|
+
) {}
|
|
135
|
+
applies(id: string): boolean {
|
|
136
|
+
const has = (table: string) =>
|
|
137
|
+
!!this.store.db
|
|
138
|
+
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?")
|
|
139
|
+
.get(table);
|
|
140
|
+
return (
|
|
141
|
+
this.store.localWorkspace(id) &&
|
|
142
|
+
!(
|
|
143
|
+
has('child_tasks') &&
|
|
144
|
+
this.store.db.prepare('SELECT 1 FROM child_tasks WHERE child_session=?').get(id)
|
|
145
|
+
) &&
|
|
146
|
+
!(
|
|
147
|
+
has('agentpod_admissions') &&
|
|
148
|
+
this.store.db.prepare('SELECT 1 FROM agentpod_admissions WHERE session_id=?').get(id)
|
|
149
|
+
)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
private latest(id: string): (InstructionsSummary & { artifact_id: string }) | null {
|
|
153
|
+
const row = this.store.db
|
|
154
|
+
.prepare(
|
|
155
|
+
"SELECT seq,payload FROM events WHERE session_id=? AND kind='instructions.loaded' ORDER BY seq DESC LIMIT 1",
|
|
156
|
+
)
|
|
157
|
+
.get(id) as { seq: number; payload: string } | undefined;
|
|
158
|
+
if (!row) return null;
|
|
159
|
+
const p = JSON.parse(row.payload);
|
|
160
|
+
return { ...p, revision: p.revision ?? 1 };
|
|
161
|
+
}
|
|
162
|
+
/** Discover the current files; record a new revision only when they changed. */
|
|
163
|
+
refresh(id: string): InstructionsSummary | null {
|
|
164
|
+
if (!this.applies(id)) return null;
|
|
165
|
+
const found = discoverInstructions(this.store.session(id).cwd, this.userFile);
|
|
166
|
+
const document: InstructionsDocument = { version: 1, ...found };
|
|
167
|
+
const current = digest(document);
|
|
168
|
+
const prior = this.latest(id);
|
|
169
|
+
if (prior?.digest === current) return this.summary(id);
|
|
170
|
+
if (!prior && !found.files.length && !found.skipped.length) return null;
|
|
171
|
+
const text = canonical(document);
|
|
172
|
+
const artifact = this.artifacts.prepare(id, text);
|
|
173
|
+
const revision = (prior?.revision ?? 0) + 1;
|
|
174
|
+
this.store.transaction(() => {
|
|
175
|
+
artifact.commit();
|
|
176
|
+
this.store.event(id, 'instructions.loaded', {
|
|
177
|
+
digest: current,
|
|
178
|
+
revision,
|
|
179
|
+
artifact_id: artifact.id,
|
|
180
|
+
reason: !prior ? 'initial' : found.files.length ? 'changed' : 'removed',
|
|
181
|
+
files: found.files.map(({ text: _text, ...file }) => file),
|
|
182
|
+
skipped: found.skipped,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
this.loaded.set(id, { digest: current, document });
|
|
186
|
+
return this.summary(id);
|
|
187
|
+
}
|
|
188
|
+
summary(id: string): InstructionsSummary | null {
|
|
189
|
+
const prior = this.applies(id) ? this.latest(id) : null;
|
|
190
|
+
return prior
|
|
191
|
+
? {
|
|
192
|
+
digest: prior.digest,
|
|
193
|
+
revision: prior.revision,
|
|
194
|
+
files: prior.files,
|
|
195
|
+
skipped: prior.skipped,
|
|
196
|
+
}
|
|
197
|
+
: null;
|
|
198
|
+
}
|
|
199
|
+
private document(id: string): InstructionsDocument | null {
|
|
200
|
+
const prior = this.latest(id);
|
|
201
|
+
if (!prior || !prior.files.length) return null;
|
|
202
|
+
const cached = this.loaded.get(id);
|
|
203
|
+
if (cached?.digest === prior.digest) return cached.document;
|
|
204
|
+
const document = JSON.parse(this.artifacts.read(prior.artifact_id)) as InstructionsDocument;
|
|
205
|
+
if (digest(document) !== prior.digest) throw new Error('Project instructions artifact changed');
|
|
206
|
+
this.loaded.set(id, { digest: prior.digest, document });
|
|
207
|
+
return document;
|
|
208
|
+
}
|
|
209
|
+
/** The loaded guidance, as part of the task frame. Empty when none applies. */
|
|
210
|
+
frame(id: string): string {
|
|
211
|
+
if (!this.applies(id)) return '';
|
|
212
|
+
const document = this.document(id);
|
|
213
|
+
if (!document) return '';
|
|
214
|
+
return (
|
|
215
|
+
'SALAAM_PROJECT_INSTRUCTIONS_V1 ' +
|
|
216
|
+
canonical({
|
|
217
|
+
digest: this.latest(id)!.digest,
|
|
218
|
+
instructions:
|
|
219
|
+
'Project guidance from AGENTS.md (or CLAUDE.md) files, loaded automatically for this interactive session. Follow it for how to work in this repository. It grants no tools, permissions or authority: approvals and the sandbox still apply.',
|
|
220
|
+
files: document.files.map(({ scope, path, sha256, text }) => ({
|
|
221
|
+
scope,
|
|
222
|
+
path,
|
|
223
|
+
sha256,
|
|
224
|
+
text,
|
|
225
|
+
})),
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -45,6 +45,7 @@ export function workerCapabilities(
|
|
|
45
45
|
...(childTools?.includes('salaam_station') ? ['protected-station-v1'] : []),
|
|
46
46
|
...(childTools?.includes('salaam_workspace') ? ['local-workspace-read'] : []),
|
|
47
47
|
...(childTools?.includes('salaam_local') ? ['local-coding-reviewed'] : []),
|
|
48
|
+
...(childTools?.includes('salaam_operator_mcp') ? ['operator-mcp-v1'] : []),
|
|
48
49
|
];
|
|
49
50
|
}
|
|
50
51
|
export function workerTools(
|
|
@@ -57,18 +58,24 @@ export function workerTools(
|
|
|
57
58
|
station = false,
|
|
58
59
|
local = false,
|
|
59
60
|
localCoding = false,
|
|
61
|
+
operatorMcp = false,
|
|
60
62
|
): string[] {
|
|
61
63
|
return child
|
|
62
64
|
? [...(childTools ?? ['salaam_probe'])].sort()
|
|
63
65
|
: [
|
|
64
66
|
...(local ? ['salaam_history', 'salaam_workspace'] : TOOLS),
|
|
65
|
-
|
|
67
|
+
// Interactive coding also gets built-in web search and fetch (operator ruling 2026-10-31).
|
|
68
|
+
...(localCoding
|
|
69
|
+
? ['salaam_local', 'salaam_tasks', 'salaam_web_fetch', 'salaam_web_search']
|
|
70
|
+
: []),
|
|
66
71
|
...(effects ? ['salaam_effect'] : []),
|
|
67
72
|
...(coding?.files ? ['salaam_files'] : []),
|
|
68
73
|
...(coding?.commands ? ['salaam_command'] : []),
|
|
69
74
|
...(mcp ? ['salaam_mcp'] : []),
|
|
70
75
|
...(toolkit ? ['salaam_toolkit'] : []),
|
|
71
|
-
|
|
76
|
+
// salaam_board: read-only board reads, served by the hub (operator ruling 2026-10-28).
|
|
77
|
+
...(station ? ['salaam_board', 'salaam_station'] : []),
|
|
78
|
+
...(operatorMcp ? ['salaam_operator_mcp'] : []),
|
|
72
79
|
].sort();
|
|
73
80
|
}
|
|
74
81
|
export interface ChildTask {
|
|
@@ -89,6 +96,7 @@ export interface ChildTask {
|
|
|
89
96
|
}
|
|
90
97
|
export type WorkerState = 'stopped' | 'starting' | 'ready' | 'unhealthy' | 'exited';
|
|
91
98
|
export interface Session {
|
|
99
|
+
mcp_servers?: import('./operator-mcp.js').McpServerProvenance[];
|
|
92
100
|
id: string;
|
|
93
101
|
cwd: string;
|
|
94
102
|
revision: number;
|
|
@@ -102,6 +110,11 @@ export interface Session {
|
|
|
102
110
|
capabilities: string[];
|
|
103
111
|
seq: number;
|
|
104
112
|
provider_route?: ProviderRoute;
|
|
113
|
+
/** Interactive sessions: the AGENTS.md set loaded automatically (project-instructions.ts). */
|
|
114
|
+
project_instructions?: import('./project-instructions.js').InstructionsSummary;
|
|
115
|
+
/** Desk sessions in the session list: the newest prompt and when the transcript last changed. */
|
|
116
|
+
last_prompt?: string;
|
|
117
|
+
last_active_at?: string;
|
|
105
118
|
provider_capabilities?: {
|
|
106
119
|
output_token_cap: boolean;
|
|
107
120
|
durable_tasks: boolean;
|
|
@@ -174,7 +187,16 @@ export interface SessionSnapshot extends Session {
|
|
|
174
187
|
control: ControlView;
|
|
175
188
|
attempts: (Attempt & { result_truncated: boolean })[];
|
|
176
189
|
submissions: (Submission & { text_truncated: boolean })[];
|
|
177
|
-
messages: {
|
|
190
|
+
messages: {
|
|
191
|
+
entry_id: string;
|
|
192
|
+
role: string;
|
|
193
|
+
text: string;
|
|
194
|
+
truncated: boolean;
|
|
195
|
+
/** Tool calls made just before this message, oldest first. */
|
|
196
|
+
tool_calls?: string[];
|
|
197
|
+
}[];
|
|
198
|
+
/** Tool calls after the last message, e.g. a turn still running. */
|
|
199
|
+
trailing_tool_calls?: string[];
|
|
178
200
|
history_truncated: boolean;
|
|
179
201
|
approvals: Approval[];
|
|
180
202
|
operations: Operation[];
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Store } from './store.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A provider 429 on a step is retried as a fresh admitted attempt of that step, after a backoff,
|
|
5
|
+
* up to RATE_LIMIT_MAX_FAILURES rate-limited attempts in all. Each retry is a new attempt with its
|
|
6
|
+
* own admission and budget charge, never a replay of the refused request. After the last one the
|
|
7
|
+
* task stops with RATE_LIMIT_STOP, a block the stopped-run recovery can prove and settle.
|
|
8
|
+
*/
|
|
9
|
+
export const RATE_LIMIT_MAX_FAILURES = 3;
|
|
10
|
+
export const RATE_LIMIT_BACKOFF_MS = [30_000, 60_000, 120_000];
|
|
11
|
+
export const RATE_LIMIT_STOP = 'Provider rate limit reached';
|
|
12
|
+
|
|
13
|
+
type Step = { id: string; state: string; attempt_id: string | null };
|
|
14
|
+
export type RetryDecision =
|
|
15
|
+
| { action: 'none' }
|
|
16
|
+
| { action: 'wait' }
|
|
17
|
+
| { action: 'retry'; step: string; n: number }
|
|
18
|
+
| { action: 'stop'; reason: string };
|
|
19
|
+
|
|
20
|
+
export class RateLimitRetries {
|
|
21
|
+
constructor(private readonly store: Store) {
|
|
22
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS task_rate_limits(
|
|
23
|
+
task_id TEXT NOT NULL,
|
|
24
|
+
step_id TEXT NOT NULL,
|
|
25
|
+
attempt_id TEXT NOT NULL UNIQUE,
|
|
26
|
+
failures INTEGER NOT NULL,
|
|
27
|
+
retry_at INTEGER NOT NULL,
|
|
28
|
+
PRIMARY KEY(task_id, step_id, failures))`);
|
|
29
|
+
}
|
|
30
|
+
/** Whether this attempt failed because the provider rate-limited it. */
|
|
31
|
+
rateLimited(attempt: string): boolean {
|
|
32
|
+
const row = this.store.db.prepare('SELECT state,outcome FROM attempts WHERE id=?').get(attempt);
|
|
33
|
+
if (!row || row.state !== 'failed' || typeof row.outcome !== 'string') return false;
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(row.outcome)?.failure?.kind === 'rate_limit';
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** What the driver does with a task whose step failed. Records each rate-limited attempt once. */
|
|
41
|
+
decide(task: { id: string; steps: Step[] }, now = Date.now()): RetryDecision {
|
|
42
|
+
const failed = task.steps.filter((s) =>
|
|
43
|
+
['failed', 'interrupted', 'cancelled'].includes(s.state),
|
|
44
|
+
);
|
|
45
|
+
const step = failed[0];
|
|
46
|
+
if (failed.length !== 1 || !step || step.state !== 'failed' || !step.attempt_id)
|
|
47
|
+
return { action: 'none' };
|
|
48
|
+
if (!this.rateLimited(step.attempt_id)) return { action: 'none' };
|
|
49
|
+
let row = this.store.db
|
|
50
|
+
.prepare('SELECT failures,retry_at FROM task_rate_limits WHERE attempt_id=?')
|
|
51
|
+
.get(step.attempt_id) as { failures: number; retry_at: number } | undefined;
|
|
52
|
+
if (!row) {
|
|
53
|
+
const prior = Number(
|
|
54
|
+
this.store.db
|
|
55
|
+
.prepare('SELECT count(*) AS n FROM task_rate_limits WHERE task_id=? AND step_id=?')
|
|
56
|
+
.get(task.id, step.id)!.n,
|
|
57
|
+
);
|
|
58
|
+
const failures = prior + 1;
|
|
59
|
+
const retry_at = now + (RATE_LIMIT_BACKOFF_MS[failures - 1] ?? RATE_LIMIT_BACKOFF_MS.at(-1)!);
|
|
60
|
+
this.store.db
|
|
61
|
+
.prepare('INSERT INTO task_rate_limits VALUES (?,?,?,?,?)')
|
|
62
|
+
.run(task.id, step.id, step.attempt_id, failures, retry_at);
|
|
63
|
+
row = { failures, retry_at };
|
|
64
|
+
}
|
|
65
|
+
if (row.failures >= RATE_LIMIT_MAX_FAILURES) return { action: 'stop', reason: RATE_LIMIT_STOP };
|
|
66
|
+
if (now < row.retry_at) return { action: 'wait' };
|
|
67
|
+
return { action: 'retry', step: step.id, n: row.failures };
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/recurring-work.ts
CHANGED
|
@@ -71,9 +71,12 @@ export class RepositorySnapshots {
|
|
|
71
71
|
const tree = this.tree(binding),
|
|
72
72
|
action = string(args.action, 'action', 32),
|
|
73
73
|
offset = integer(args.offset ?? 0, 'offset');
|
|
74
|
+
// A snapshot-only reviewer (the critic) has no workspace; leave those fields out rather than
|
|
75
|
+
// undefined, since every read result is digested into the journal (card_5dcbd5d9be5c4183).
|
|
74
76
|
const identity = {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
...(binding.workspace
|
|
78
|
+
? { workspace_id: binding.workspace.id, revision: binding.workspace.revision }
|
|
79
|
+
: {}),
|
|
77
80
|
digest: binding.repository!.digest,
|
|
78
81
|
artifact_id: binding.repository!.artifact_id,
|
|
79
82
|
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* "Approve for this run" (approval scopes, item B). A person answering one workspace-write approval
|
|
6
|
+
* may cover the rest of the run's writes inside the run's own directory. Each later write is still
|
|
7
|
+
* prepared, digest-bound and journaled as its own operation; the grant only answers its approval.
|
|
8
|
+
*
|
|
9
|
+
* A grant covers a station patch only when it is a write (never a delete: every delete asks), its
|
|
10
|
+
* path lies under `runs/<runId>/`, and no segment is empty, `.`, `..` or hidden (a leading dot).
|
|
11
|
+
* It is bound to the session, task, plan revision and binding digest it was given under, and ends
|
|
12
|
+
* when any of them changes or the task settles. Anything outside it asks as before.
|
|
13
|
+
*/
|
|
14
|
+
export interface RunGrant {
|
|
15
|
+
id: string;
|
|
16
|
+
session_id: string;
|
|
17
|
+
task_id: string;
|
|
18
|
+
plan_revision: number;
|
|
19
|
+
binding_digest: string;
|
|
20
|
+
path_prefix: string;
|
|
21
|
+
approval_id: string;
|
|
22
|
+
granted_by: string;
|
|
23
|
+
created_at: string;
|
|
24
|
+
revoked_at: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const runGrantPrefix = (runId: string): string | null =>
|
|
28
|
+
/^run_[A-Za-z0-9]{6,}$/.test(runId) ? `runs/${runId}/` : null;
|
|
29
|
+
|
|
30
|
+
/** Whether a station patch's arguments are a write a grant for this prefix may answer. */
|
|
31
|
+
export function runGrantCovers(prefix: string, args: Record<string, unknown>): boolean {
|
|
32
|
+
if (args.action !== 'patch' || typeof args.content !== 'string') return false;
|
|
33
|
+
const path = args.path;
|
|
34
|
+
if (typeof path !== 'string' || !path.startsWith(prefix) || path.length === prefix.length)
|
|
35
|
+
return false;
|
|
36
|
+
return path.split('/').every((s) => s !== '' && s !== '.' && s !== '..' && !s.startsWith('.'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class RunGrants {
|
|
40
|
+
constructor(private readonly store: Store) {
|
|
41
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS run_grants(
|
|
42
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
43
|
+
session_id TEXT NOT NULL,
|
|
44
|
+
task_id TEXT NOT NULL,
|
|
45
|
+
plan_revision INTEGER NOT NULL,
|
|
46
|
+
binding_digest TEXT NOT NULL,
|
|
47
|
+
path_prefix TEXT NOT NULL,
|
|
48
|
+
approval_id TEXT NOT NULL UNIQUE,
|
|
49
|
+
granted_by TEXT NOT NULL,
|
|
50
|
+
created_at TEXT NOT NULL,
|
|
51
|
+
revoked_at TEXT)`);
|
|
52
|
+
}
|
|
53
|
+
/** Within the caller's transaction, next to the decision that granted it. Idempotent per approval. */
|
|
54
|
+
createInTransaction(input: Omit<RunGrant, 'id' | 'created_at' | 'revoked_at'>): RunGrant {
|
|
55
|
+
if (!this.store.db.isTransaction) throw Error('A run grant is created with its decision');
|
|
56
|
+
const saved = this.store.db
|
|
57
|
+
.prepare('SELECT * FROM run_grants WHERE approval_id=?')
|
|
58
|
+
.get(input.approval_id) as RunGrant | undefined;
|
|
59
|
+
if (saved) return saved;
|
|
60
|
+
const grant: RunGrant = {
|
|
61
|
+
...input,
|
|
62
|
+
id: randomUUID(),
|
|
63
|
+
created_at: new Date().toISOString(),
|
|
64
|
+
revoked_at: null,
|
|
65
|
+
};
|
|
66
|
+
this.store.db
|
|
67
|
+
.prepare('INSERT INTO run_grants VALUES (?,?,?,?,?,?,?,?,?,?)')
|
|
68
|
+
.run(
|
|
69
|
+
grant.id,
|
|
70
|
+
grant.session_id,
|
|
71
|
+
grant.task_id,
|
|
72
|
+
grant.plan_revision,
|
|
73
|
+
grant.binding_digest,
|
|
74
|
+
grant.path_prefix,
|
|
75
|
+
grant.approval_id,
|
|
76
|
+
grant.granted_by,
|
|
77
|
+
grant.created_at,
|
|
78
|
+
null,
|
|
79
|
+
);
|
|
80
|
+
this.store.event(grant.session_id, 'run_grant.created', {
|
|
81
|
+
grant_id: grant.id,
|
|
82
|
+
approval_id: grant.approval_id,
|
|
83
|
+
task_id: grant.task_id,
|
|
84
|
+
path_prefix: grant.path_prefix,
|
|
85
|
+
});
|
|
86
|
+
return grant;
|
|
87
|
+
}
|
|
88
|
+
/** The live grant for exactly this session, task, plan revision and binding, if any. */
|
|
89
|
+
active(scope: {
|
|
90
|
+
session_id: string;
|
|
91
|
+
task_id: string;
|
|
92
|
+
plan_revision: number;
|
|
93
|
+
binding_digest: string;
|
|
94
|
+
task_state: string;
|
|
95
|
+
}): RunGrant | null {
|
|
96
|
+
if (['cancelled', 'succeeded', 'succeeded_with_exception'].includes(scope.task_state))
|
|
97
|
+
return null;
|
|
98
|
+
return (
|
|
99
|
+
(this.store.db
|
|
100
|
+
.prepare(
|
|
101
|
+
'SELECT * FROM run_grants WHERE session_id=? AND task_id=? AND plan_revision=? AND binding_digest=? AND revoked_at IS NULL ORDER BY created_at DESC LIMIT 1',
|
|
102
|
+
)
|
|
103
|
+
.get(scope.session_id, scope.task_id, scope.plan_revision, scope.binding_digest) as
|
|
104
|
+
RunGrant | undefined) ?? null
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|