@superjackfruitlabs/salaam 0.0.4 → 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 +41 -0
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +23 -19
- package/dist/src/acp.js +258 -11
- 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/cli-help.js +34 -4
- package/dist/src/cli.js +55 -3
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-sandbox.js +116 -6
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host.js +96 -4
- package/dist/src/house-extension.js +133 -7
- package/dist/src/local-coding.js +553 -58
- package/dist/src/local-edits.js +168 -6
- package/dist/src/mentions.js +277 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +4 -1
- package/dist/src/service.js +342 -5
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/store.js +51 -4
- package/dist/src/supervisor.js +233 -22
- package/dist/src/task-context.js +8 -2
- package/dist/src/terminal-status.js +88 -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/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 +25 -21
- package/src/acp.ts +338 -28
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/cli-help.ts +33 -4
- package/src/cli.ts +66 -2
- package/src/command-danger.ts +886 -0
- package/src/command-sandbox.ts +128 -8
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host.ts +122 -1
- package/src/house-extension.ts +142 -7
- package/src/local-coding.ts +674 -75
- package/src/local-edits.ts +191 -8
- package/src/mentions.ts +304 -0
- package/src/pi-sessions.ts +9 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +19 -2
- package/src/service.ts +488 -5
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/store.ts +52 -3
- package/src/supervisor.ts +262 -24
- package/src/task-context.ts +9 -2
- package/src/terminal-status.ts +103 -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/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
package/src/service.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { DangerClassifier, loadDangerClassifierOptions } from './danger-classifier.js';
|
|
3
|
+
import { WebSearch, loadWebSearchConfig } from './web-search.js';
|
|
1
4
|
import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
|
|
2
5
|
import { loadPersona } from './persona.js';
|
|
3
6
|
import {
|
|
@@ -13,6 +16,7 @@ import { join, resolve } from 'node:path';
|
|
|
13
16
|
import { providerRoute } from './provider-config.js';
|
|
14
17
|
import { TaskContext } from './task-context.js';
|
|
15
18
|
import { ContextAdmission } from './context-admission.js';
|
|
19
|
+
import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
|
|
16
20
|
import { SLASH_COMMANDS } from './tui-commands.js';
|
|
17
21
|
import { TaskWork } from './task-work.js';
|
|
18
22
|
import { TaskBudget } from './task-budget.js';
|
|
@@ -28,6 +32,7 @@ import { OperatorInbox } from './operator-inbox.js';
|
|
|
28
32
|
import { RecurringWork } from './recurring-work.js';
|
|
29
33
|
import { JobWork } from './job-work.js';
|
|
30
34
|
import { Store } from './store.js';
|
|
35
|
+
import { pickerSummary } from './session-pick.js';
|
|
31
36
|
import { acquireStateLock } from './state-lock.js';
|
|
32
37
|
import { Artifacts } from './artifacts.js';
|
|
33
38
|
import { Supervisor, type SupervisorOptions } from './supervisor.js';
|
|
@@ -63,16 +68,23 @@ import { McpAdmission } from './mcp-admission.js';
|
|
|
63
68
|
import { McpWork } from './mcp-work.js';
|
|
64
69
|
import { ToolkitWork, type ToolkitWorkConfig } from './toolkit-work.js';
|
|
65
70
|
import { permissionPolicy, configurePermissions } from './autonomy.js';
|
|
66
|
-
import {
|
|
71
|
+
import { expandPrompt, storeImages, type PromptImage } from './mentions.js';
|
|
72
|
+
import { ApprovalRules } from './approval-rules.js';
|
|
73
|
+
import { chooseLocalSandbox, type SandboxLevel, resolveReadPaths } from './command-sandbox.js';
|
|
67
74
|
import { LocalCoding, type LocalSandbox } from './local-coding.js';
|
|
75
|
+
import { Hooks } from './hooks.js';
|
|
68
76
|
import { localDiff } from './local-diff.js';
|
|
69
77
|
import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
|
|
70
78
|
import { forkPiSession } from './pi-sessions.js';
|
|
79
|
+
import { TurnCheckpoints, type RewindMode } from './turn-rewind.js';
|
|
71
80
|
import { StationWork } from './station-work.js';
|
|
72
81
|
import { readToolkitConfig } from './toolkit-config.js';
|
|
73
82
|
import { probeToolsUser, serviceToolIdentity, type ToolIdentity } from './tool-identity.js';
|
|
74
83
|
|
|
75
84
|
export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'bridgeSocket'> {
|
|
85
|
+
/** The user-level AGENTS.md for interactive sessions; null turns it off. Default:
|
|
86
|
+
* /salaam/AGENTS.md or ~/.config/salaam/AGENTS.md. */
|
|
87
|
+
userInstructions?: string | null;
|
|
76
88
|
hostManifestDigest?: string;
|
|
77
89
|
/** Receipt fields for the launcher profile; reported only by a manifest-served host. */
|
|
78
90
|
hostReceipt?: Record<string, unknown>;
|
|
@@ -115,9 +127,29 @@ export interface ServiceOptions extends Omit<SupervisorOptions, 'stateDir' | 'br
|
|
|
115
127
|
/** Host config `unsandboxed_commands: "allow"`: run local commands unsandboxed on a host with no
|
|
116
128
|
* usable sandbox. Absent, they are refused (operator ruling 2026-10-30, option A). */
|
|
117
129
|
unsandboxedCommands?: 'allow';
|
|
130
|
+
/** Read-only paths outside the workspace local commands may read (resolved, `toolchain` expanded). */
|
|
131
|
+
localSandboxReadPaths?: string[];
|
|
132
|
+
/** Web search config file: optional providers (searxng, brave, tavily, exa) and their key
|
|
133
|
+
* references. Keyless official APIs answer without one (operator ruling 2026-10-31). */
|
|
134
|
+
webSearchConfig?: string;
|
|
135
|
+
/** Phase 4c: the optional small-model command reviewer (a JSON file); absent means off. */
|
|
136
|
+
dangerClassifierConfig?: string;
|
|
137
|
+
/** A host's reviewed pin: one option's name (sessions cannot switch or turn it off), or "off"
|
|
138
|
+
* (the reviewer is forbidden). Absent: sessions choose from the config's options. */
|
|
139
|
+
dangerClassifierPin?: string;
|
|
140
|
+
/** The user's approval rules file (default ~/.config/salaam/permissions.json). */
|
|
141
|
+
permissionsFile?: string;
|
|
142
|
+
/** Your hooks file (Phase 4b); defaults to ~/.config/salaam/hooks.json or $SALAAM_HOOKS_FILE. */
|
|
143
|
+
hooksFile?: string;
|
|
118
144
|
/** Host config `command_network: "broker"`: sandboxed local commands reach the network only
|
|
119
145
|
* through this stk local-commands socket (operator rulings 2026-10-30). */
|
|
120
146
|
commandNetwork?: { socket: string };
|
|
147
|
+
/** Phase 2b: may an interactive session choose full access? Default true for local desk use;
|
|
148
|
+
* a host started from a manifest passes its reviewed `full_access_sessions` setting. */
|
|
149
|
+
allowFullAccessSessions?: boolean;
|
|
150
|
+
/** Whether an interactive session may choose YOLO (no sandbox, no approvals). Desk default allows
|
|
151
|
+
* it; a host started from a manifest passes its reviewed `yolo_sessions` setting. */
|
|
152
|
+
allowYoloSessions?: boolean;
|
|
121
153
|
/** Internal test seam: argv prefix replacing `sudo -n -u <toolsUser> --`. */
|
|
122
154
|
toolsLauncher?: string[];
|
|
123
155
|
codingSocket?: string;
|
|
@@ -178,7 +210,24 @@ export class SalaamService {
|
|
|
178
210
|
private readonly hostReceipt: Record<string, unknown> | null;
|
|
179
211
|
private readonly operatorAccess: { group: string; gid: number } | null;
|
|
180
212
|
private readonly agentpodWorkProgression: boolean;
|
|
213
|
+
/** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
|
|
214
|
+
private readonly allowFullAccessSessions: boolean;
|
|
215
|
+
private readonly allowYoloSessions: boolean;
|
|
216
|
+
private readonly reviewers = new Map<string, DangerClassifier>();
|
|
217
|
+
private reviewerDefault = 'off';
|
|
218
|
+
private reviewerPin?: string;
|
|
219
|
+
/** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
|
|
220
|
+
private reviewerChoice(session: string): string {
|
|
221
|
+
if (this.reviewerPin) return this.reviewerPin;
|
|
222
|
+
const row = this.store.db
|
|
223
|
+
.prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
|
|
224
|
+
.get(session) as { choice: string } | undefined;
|
|
225
|
+
const choice = row?.choice ?? this.reviewerDefault;
|
|
226
|
+
return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
|
|
227
|
+
}
|
|
181
228
|
private constructor(options: ServiceOptions) {
|
|
229
|
+
this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
|
|
230
|
+
this.allowYoloSessions = options.allowYoloSessions !== false;
|
|
182
231
|
this.agentpodWorkProgression = options.agentpodWorkProgression === true;
|
|
183
232
|
if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
|
|
184
233
|
throw Error('Strict work requires reviewed AgentPod protected station configuration');
|
|
@@ -227,7 +276,10 @@ export class SalaamService {
|
|
|
227
276
|
this.socket = join(this.stateDir, 'operator.sock');
|
|
228
277
|
this.bridgeSocket = join(this.stateDir, 'bridge.sock');
|
|
229
278
|
if (Buffer.byteLength(this.bridgeSocket) > 100)
|
|
230
|
-
throw new Error(
|
|
279
|
+
throw new Error(
|
|
280
|
+
`State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
|
|
281
|
+
'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.',
|
|
282
|
+
);
|
|
231
283
|
this.release = acquireStateLock(this.stateDir);
|
|
232
284
|
try {
|
|
233
285
|
this.store = new Store(join(this.stateDir, 'journal.sqlite'));
|
|
@@ -240,6 +292,18 @@ export class SalaamService {
|
|
|
240
292
|
this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
|
|
241
293
|
this.tasks = new TaskContext(this.store, this.artifacts);
|
|
242
294
|
this.tasks.admitted = new ContextAdmission(this.store);
|
|
295
|
+
this.tasks.instructions = new ProjectInstructions(
|
|
296
|
+
this.store,
|
|
297
|
+
this.artifacts,
|
|
298
|
+
options.userInstructions === undefined
|
|
299
|
+
? userInstructionsPath()
|
|
300
|
+
: options.userInstructions,
|
|
301
|
+
);
|
|
302
|
+
this.tasks.checkpoints = new TurnCheckpoints(
|
|
303
|
+
this.store,
|
|
304
|
+
this.stateDir,
|
|
305
|
+
this.toolIdentity.mode !== 'tools-user',
|
|
306
|
+
);
|
|
243
307
|
this.taskWork = new TaskWork(this.store, this.tasks);
|
|
244
308
|
this.taskBudget = new TaskBudget(this.store, this.taskWork);
|
|
245
309
|
if (options.admitModelRequest) {
|
|
@@ -374,6 +438,9 @@ export class SalaamService {
|
|
|
374
438
|
const localSandbox: LocalSandbox = {
|
|
375
439
|
...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
|
|
376
440
|
allowUnsandboxed: options.unsandboxedCommands === 'allow',
|
|
441
|
+
...(options.localSandboxReadPaths?.length
|
|
442
|
+
? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
|
|
443
|
+
: {}),
|
|
377
444
|
...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
|
|
378
445
|
};
|
|
379
446
|
this.localCoding = new LocalCoding(
|
|
@@ -383,7 +450,48 @@ export class SalaamService {
|
|
|
383
450
|
this.toolIdentity,
|
|
384
451
|
localSandbox,
|
|
385
452
|
);
|
|
453
|
+
this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
|
|
454
|
+
this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
|
|
455
|
+
this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
|
|
386
456
|
this.supervisor.localCoding = this.localCoding;
|
|
457
|
+
if (options.dangerClassifierConfig) {
|
|
458
|
+
const loaded = loadDangerClassifierOptions(
|
|
459
|
+
JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')),
|
|
460
|
+
);
|
|
461
|
+
for (const o of loaded.options) this.reviewers.set(o.name, new DangerClassifier(o));
|
|
462
|
+
this.reviewerDefault = loaded.default;
|
|
463
|
+
}
|
|
464
|
+
if (options.dangerClassifierPin !== undefined) {
|
|
465
|
+
if (
|
|
466
|
+
options.dangerClassifierPin !== 'off' &&
|
|
467
|
+
!this.reviewers.has(options.dangerClassifierPin)
|
|
468
|
+
)
|
|
469
|
+
throw Error(
|
|
470
|
+
`The pinned command reviewer ${options.dangerClassifierPin} is not configured`,
|
|
471
|
+
);
|
|
472
|
+
this.reviewerPin = options.dangerClassifierPin;
|
|
473
|
+
}
|
|
474
|
+
if (this.reviewers.size) {
|
|
475
|
+
this.store.db.exec(
|
|
476
|
+
'CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)',
|
|
477
|
+
);
|
|
478
|
+
this.localCoding.classifier = {
|
|
479
|
+
review: (s, command, cwd) => {
|
|
480
|
+
const reviewer = this.reviewers.get(this.reviewerChoice(s));
|
|
481
|
+
return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
const local = this.localCoding;
|
|
486
|
+
this.supervisor.hooks = new Hooks(
|
|
487
|
+
this.store,
|
|
488
|
+
(session, command, input, timeout) => local.runHook(session, command, input, timeout),
|
|
489
|
+
options.hooksFile,
|
|
490
|
+
);
|
|
491
|
+
const webConfig = options.webSearchConfig
|
|
492
|
+
? loadWebSearchConfig(options.webSearchConfig)
|
|
493
|
+
: { providers: [] };
|
|
494
|
+
this.supervisor.web = new WebSearch(webConfig);
|
|
387
495
|
this.agents = new AgentProfiles(
|
|
388
496
|
this.store,
|
|
389
497
|
this.supervisor.route,
|
|
@@ -1978,7 +2086,10 @@ export class SalaamService {
|
|
|
1978
2086
|
this.send(
|
|
1979
2087
|
res,
|
|
1980
2088
|
200,
|
|
1981
|
-
this.store.sessions().map((x) =>
|
|
2089
|
+
this.store.sessions().map((x) => {
|
|
2090
|
+
const session = this.supervisor.inspect(x.id);
|
|
2091
|
+
return { ...session, ...pickerSummary(session, this.store) };
|
|
2092
|
+
}),
|
|
1982
2093
|
);
|
|
1983
2094
|
return;
|
|
1984
2095
|
}
|
|
@@ -2121,13 +2232,61 @@ export class SalaamService {
|
|
|
2121
2232
|
}
|
|
2122
2233
|
if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
|
|
2123
2234
|
const id = parts[2];
|
|
2235
|
+
// Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
|
|
2236
|
+
// and remove. Adding or removing needs the session's control lease, like the permission policy.
|
|
2237
|
+
if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
|
|
2238
|
+
const rules = this.localCoding.rules!;
|
|
2239
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
2240
|
+
this.send(res, 200, {
|
|
2241
|
+
applies: rules.applies(id),
|
|
2242
|
+
rules: rules.list(id),
|
|
2243
|
+
problems: rules.problems(id),
|
|
2244
|
+
});
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const body = object(await readJson(req));
|
|
2248
|
+
this.store.authorizeControl(id, {
|
|
2249
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2250
|
+
token: string(body.token, 'token', 128),
|
|
2251
|
+
});
|
|
2252
|
+
if (parts.length === 4 && req.method === 'POST') {
|
|
2253
|
+
const scope = string(body.scope, 'scope');
|
|
2254
|
+
if (scope !== 'session' && scope !== 'repo')
|
|
2255
|
+
throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
|
|
2256
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
2257
|
+
this.send(
|
|
2258
|
+
res,
|
|
2259
|
+
200,
|
|
2260
|
+
body.operation_id !== undefined
|
|
2261
|
+
? rules.addFromOperation(
|
|
2262
|
+
id,
|
|
2263
|
+
string(body.operation_id, 'operation_id', 128),
|
|
2264
|
+
scope,
|
|
2265
|
+
request,
|
|
2266
|
+
)
|
|
2267
|
+
: rules.add(id, scope, body.rule, request),
|
|
2268
|
+
);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
|
|
2272
|
+
rules.remove(id, string(parts[4], 'rule', 128));
|
|
2273
|
+
this.send(res, 200, { removed: parts[4] });
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2124
2277
|
if (parts.length === 4 && parts[3] === 'permissions') {
|
|
2125
2278
|
if (req.method === 'GET') {
|
|
2126
2279
|
this.send(res, 200, {
|
|
2127
2280
|
policy: permissionPolicy(this.store, id),
|
|
2128
2281
|
configurable: this.store.localWorkspace(id),
|
|
2129
2282
|
coding: this.store.localCoding(id),
|
|
2130
|
-
|
|
2283
|
+
// Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
|
|
2284
|
+
command_auto_approval: this.store.localCoding(id),
|
|
2285
|
+
full_access_allowed: this.allowFullAccessSessions,
|
|
2286
|
+
yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
|
|
2287
|
+
...(this.store.localCoding(id)
|
|
2288
|
+
? { command_sandbox: this.localCoding.commandSandbox(id) }
|
|
2289
|
+
: {}),
|
|
2131
2290
|
});
|
|
2132
2291
|
return;
|
|
2133
2292
|
}
|
|
@@ -2146,17 +2305,152 @@ export class SalaamService {
|
|
|
2146
2305
|
client_id: string(body.client_id, 'client_id', 128),
|
|
2147
2306
|
token: string(body.token, 'token', 128),
|
|
2148
2307
|
},
|
|
2308
|
+
{
|
|
2309
|
+
allowFullAccess: this.allowFullAccessSessions,
|
|
2310
|
+
allowYolo: this.allowYoloSessions,
|
|
2311
|
+
deskSession: !!this.localCoding.rules?.applies(id),
|
|
2312
|
+
},
|
|
2149
2313
|
),
|
|
2150
2314
|
);
|
|
2151
2315
|
return;
|
|
2152
2316
|
}
|
|
2153
2317
|
}
|
|
2318
|
+
if (parts.length === 4 && parts[3] === 'danger-classifier') {
|
|
2319
|
+
const view = () => {
|
|
2320
|
+
const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off',
|
|
2321
|
+
chosen = this.reviewers.get(choice)?.config;
|
|
2322
|
+
return {
|
|
2323
|
+
configured: this.reviewers.size > 0,
|
|
2324
|
+
enabled: !!chosen,
|
|
2325
|
+
...(chosen
|
|
2326
|
+
? {
|
|
2327
|
+
provider: chosen.provider,
|
|
2328
|
+
model: chosen.model,
|
|
2329
|
+
on_unavailable: chosen.on_unavailable,
|
|
2330
|
+
}
|
|
2331
|
+
: {}),
|
|
2332
|
+
choice,
|
|
2333
|
+
pinned: this.reviewerPin !== undefined,
|
|
2334
|
+
options: [...this.reviewers.values()].map(({ config: o }) => ({
|
|
2335
|
+
name: o.name,
|
|
2336
|
+
provider: o.provider,
|
|
2337
|
+
model: o.model,
|
|
2338
|
+
})),
|
|
2339
|
+
};
|
|
2340
|
+
};
|
|
2341
|
+
if (req.method === 'GET') {
|
|
2342
|
+
this.send(res, 200, view());
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
if (req.method === 'POST') {
|
|
2346
|
+
const body = object(await readJson(req));
|
|
2347
|
+
this.store.authorizeControl(id, {
|
|
2348
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2349
|
+
token: string(body.token, 'token', 128),
|
|
2350
|
+
});
|
|
2351
|
+
// `enabled` is the earlier on/off form: on means the config's default.
|
|
2352
|
+
const choice =
|
|
2353
|
+
typeof body.enabled === 'boolean'
|
|
2354
|
+
? body.enabled
|
|
2355
|
+
? this.reviewerDefault === 'off'
|
|
2356
|
+
? [...this.reviewers.keys()][0]
|
|
2357
|
+
: this.reviewerDefault
|
|
2358
|
+
: 'off'
|
|
2359
|
+
: string(body.choice, 'choice', 64);
|
|
2360
|
+
if (!this.reviewers.size && choice !== 'off')
|
|
2361
|
+
throw new ServiceError(
|
|
2362
|
+
'classifier_unavailable',
|
|
2363
|
+
'No command reviewer is configured on this host (--danger-classifier-config)',
|
|
2364
|
+
);
|
|
2365
|
+
if (this.reviewerPin !== undefined)
|
|
2366
|
+
throw new ServiceError(
|
|
2367
|
+
'classifier_pinned',
|
|
2368
|
+
`This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`,
|
|
2369
|
+
403,
|
|
2370
|
+
);
|
|
2371
|
+
if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
|
|
2372
|
+
throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
|
|
2373
|
+
if (this.reviewers.size)
|
|
2374
|
+
this.store.transaction(() => {
|
|
2375
|
+
this.store.db
|
|
2376
|
+
.prepare(
|
|
2377
|
+
'INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice',
|
|
2378
|
+
)
|
|
2379
|
+
.run(id, choice!);
|
|
2380
|
+
this.store.event(id, 'permission.classifier-set', { choice });
|
|
2381
|
+
});
|
|
2382
|
+
this.send(res, 200, view());
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
// Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
|
|
2387
|
+
if (parts[3] === 'hooks' && this.supervisor.hooks) {
|
|
2388
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
2389
|
+
throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
|
|
2390
|
+
const hooks = this.supervisor.hooks;
|
|
2391
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
2392
|
+
this.send(res, 200, { sources: hooks.sources(id) });
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
if (parts.length === 5 && req.method === 'POST') {
|
|
2396
|
+
const body = object(await readJson(req));
|
|
2397
|
+
this.store.authorizeControl(id, {
|
|
2398
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2399
|
+
token: string(body.token, 'token', 128),
|
|
2400
|
+
});
|
|
2401
|
+
if (parts[4] === 'trust') {
|
|
2402
|
+
this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
if (parts[4] === 'revoke') {
|
|
2406
|
+
hooks.revoke(id);
|
|
2407
|
+
this.send(res, 200, { revoked: true });
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
if (parts[3] === 'local-tasks' && this.localCoding) {
|
|
2413
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
2414
|
+
throw new ServiceError(
|
|
2415
|
+
'background_denied',
|
|
2416
|
+
'Background commands are not admitted',
|
|
2417
|
+
403,
|
|
2418
|
+
);
|
|
2419
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
2420
|
+
this.send(res, 200, { tasks: this.localCoding.tasks(id) });
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (parts.length === 5 && req.method === 'GET') {
|
|
2424
|
+
this.send(res, 200, this.localCoding.taskOutput(id, parts[4]!));
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
|
|
2428
|
+
this.send(res, 200, this.localCoding.stopTask(id, parts[4]!));
|
|
2429
|
+
return;
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2154
2432
|
if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
|
|
2155
2433
|
if (!this.store.localWorkspace(id))
|
|
2156
2434
|
throw new ServiceError('local_denied', 'Local workspace required');
|
|
2157
2435
|
this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
|
|
2158
2436
|
return;
|
|
2159
2437
|
}
|
|
2438
|
+
// An edit request's exact files, before and after, so an editor can show it as a diff.
|
|
2439
|
+
if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
|
|
2440
|
+
if (!this.store.localCoding(id))
|
|
2441
|
+
throw new ServiceError('local_denied', 'Local coding session required');
|
|
2442
|
+
this.send(
|
|
2443
|
+
res,
|
|
2444
|
+
200,
|
|
2445
|
+
this.localCoding.edits(
|
|
2446
|
+
id,
|
|
2447
|
+
url.searchParams.has('operation_id')
|
|
2448
|
+
? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
|
|
2449
|
+
: { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) },
|
|
2450
|
+
),
|
|
2451
|
+
);
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2160
2454
|
if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
|
|
2161
2455
|
const body = object(await readJson(req));
|
|
2162
2456
|
this.send(
|
|
@@ -2227,6 +2521,132 @@ export class SalaamService {
|
|
|
2227
2521
|
return;
|
|
2228
2522
|
}
|
|
2229
2523
|
}
|
|
2524
|
+
if (
|
|
2525
|
+
(parts.length === 4 || parts.length === 5) &&
|
|
2526
|
+
(parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')
|
|
2527
|
+
) {
|
|
2528
|
+
const checkpoints = this.tasks.checkpoints!;
|
|
2529
|
+
// Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
|
|
2530
|
+
const running = <T extends object | null>(value: T): T =>
|
|
2531
|
+
value && this.localCoding
|
|
2532
|
+
? {
|
|
2533
|
+
...value,
|
|
2534
|
+
background_running: this.localCoding
|
|
2535
|
+
.tasks(id)
|
|
2536
|
+
.filter((t) => t.state === 'running').length,
|
|
2537
|
+
}
|
|
2538
|
+
: value;
|
|
2539
|
+
const controller = (body: Record<string, unknown>) => ({
|
|
2540
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2541
|
+
token: string(body.token, 'token', 128),
|
|
2542
|
+
});
|
|
2543
|
+
const idle = () => {
|
|
2544
|
+
this.supervisor.assertIdle(id);
|
|
2545
|
+
if (
|
|
2546
|
+
this.store.db
|
|
2547
|
+
.prepare(
|
|
2548
|
+
"SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')",
|
|
2549
|
+
)
|
|
2550
|
+
.get(id)
|
|
2551
|
+
)
|
|
2552
|
+
throw new ServiceError('session_busy', 'Wait for the current turn to finish');
|
|
2553
|
+
};
|
|
2554
|
+
if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
|
|
2555
|
+
this.send(res, 200, { checkpoints: checkpoints.list(id) });
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
|
|
2559
|
+
this.send(
|
|
2560
|
+
res,
|
|
2561
|
+
200,
|
|
2562
|
+
running(
|
|
2563
|
+
checkpoints.reviewRewind(
|
|
2564
|
+
id,
|
|
2565
|
+
string(url.searchParams.get('attempt'), 'attempt', 128),
|
|
2566
|
+
string(url.searchParams.get('mode'), 'mode', 16) as RewindMode,
|
|
2567
|
+
),
|
|
2568
|
+
),
|
|
2569
|
+
);
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
|
|
2573
|
+
if (this.agentpod)
|
|
2574
|
+
throw new ServiceError(
|
|
2575
|
+
'admission_required',
|
|
2576
|
+
'Managed station sessions cannot rewind',
|
|
2577
|
+
);
|
|
2578
|
+
const body = object(await readJson(req));
|
|
2579
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
2580
|
+
this.send(
|
|
2581
|
+
res,
|
|
2582
|
+
200,
|
|
2583
|
+
checkpoints.rewind(
|
|
2584
|
+
id,
|
|
2585
|
+
request,
|
|
2586
|
+
string(body.attempt_id, 'attempt_id', 128),
|
|
2587
|
+
string(body.mode, 'mode', 16) as RewindMode,
|
|
2588
|
+
string(body.review_digest, 'review_digest', 128),
|
|
2589
|
+
controller(body),
|
|
2590
|
+
idle,
|
|
2591
|
+
(leaf, summary) =>
|
|
2592
|
+
this.store.forkLocal(
|
|
2593
|
+
id,
|
|
2594
|
+
request + ':fork',
|
|
2595
|
+
this.store.session(id).revision,
|
|
2596
|
+
controller(body),
|
|
2597
|
+
(source, target) => {
|
|
2598
|
+
const dir = join(this.stateDir, 'workers', target, 'sessions');
|
|
2599
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
2600
|
+
return forkPiSession(source.pi_session_file!, source.cwd, dir, target, {
|
|
2601
|
+
leaf,
|
|
2602
|
+
summary,
|
|
2603
|
+
});
|
|
2604
|
+
},
|
|
2605
|
+
{ leaf },
|
|
2606
|
+
),
|
|
2607
|
+
),
|
|
2608
|
+
);
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
|
|
2612
|
+
this.send(res, 200, { review: running(checkpoints.review(id)) });
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
|
|
2616
|
+
const q = (name: string) => string(url.searchParams.get(name), name, 1024);
|
|
2617
|
+
this.send(res, 200, {
|
|
2618
|
+
hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
|
|
2619
|
+
});
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
|
|
2623
|
+
const body = object(await readJson(req));
|
|
2624
|
+
const hunk =
|
|
2625
|
+
body.hunk === undefined
|
|
2626
|
+
? undefined
|
|
2627
|
+
: {
|
|
2628
|
+
text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
|
|
2629
|
+
patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
|
|
2630
|
+
};
|
|
2631
|
+
this.send(res, 200, {
|
|
2632
|
+
review: running(
|
|
2633
|
+
checkpoints.act(
|
|
2634
|
+
id,
|
|
2635
|
+
{
|
|
2636
|
+
action: string(body.action, 'action', 16) as 'accept' | 'revert',
|
|
2637
|
+
path: string(body.path, 'path', 1024),
|
|
2638
|
+
...(hunk ? { hunk } : {}),
|
|
2639
|
+
base: string(body.base, 'base', 128),
|
|
2640
|
+
current: string(body.current, 'current', 128),
|
|
2641
|
+
},
|
|
2642
|
+
controller(body),
|
|
2643
|
+
idle,
|
|
2644
|
+
),
|
|
2645
|
+
),
|
|
2646
|
+
});
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2230
2650
|
if (parts.length === 4 && parts[3] === 'fork' && req.method === 'POST') {
|
|
2231
2651
|
if (this.agentpod)
|
|
2232
2652
|
throw new ServiceError(
|
|
@@ -2830,6 +3250,48 @@ export class SalaamService {
|
|
|
2830
3250
|
);
|
|
2831
3251
|
return;
|
|
2832
3252
|
}
|
|
3253
|
+
if (parts[3] === 'route' && req.method === 'POST') {
|
|
3254
|
+
const body = object(await readJson(req));
|
|
3255
|
+
this.store.authorizeControl(id, {
|
|
3256
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
3257
|
+
token: string(body.token, 'token', 128),
|
|
3258
|
+
});
|
|
3259
|
+
if (!this.tasks.instructions!.applies(id))
|
|
3260
|
+
throw new ServiceError(
|
|
3261
|
+
'route_switch_unavailable',
|
|
3262
|
+
'Only interactive sessions can switch model mid-session',
|
|
3263
|
+
409,
|
|
3264
|
+
);
|
|
3265
|
+
this.supervisor.assertIdle(id);
|
|
3266
|
+
const input = object(body.route);
|
|
3267
|
+
const requested = providerRoute(
|
|
3268
|
+
string(input.provider, 'provider'),
|
|
3269
|
+
input.model === undefined ? undefined : string(input.model, 'model'),
|
|
3270
|
+
undefined,
|
|
3271
|
+
undefined,
|
|
3272
|
+
undefined,
|
|
3273
|
+
input.reasoning as import('./provider-config.js').ReasoningLevel | undefined,
|
|
3274
|
+
);
|
|
3275
|
+
const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
|
|
3276
|
+
throw new ServiceError(
|
|
3277
|
+
'provider_auth_required',
|
|
3278
|
+
`Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`,
|
|
3279
|
+
400,
|
|
3280
|
+
);
|
|
3281
|
+
});
|
|
3282
|
+
if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
|
|
3283
|
+
throw new ServiceError(
|
|
3284
|
+
'provider_output_cap_changed',
|
|
3285
|
+
'That route enforces output caps differently; start a new session for it',
|
|
3286
|
+
409,
|
|
3287
|
+
);
|
|
3288
|
+
this.supervisor.assertIdle(id);
|
|
3289
|
+
this.store.switchRoute(id, prepared.route);
|
|
3290
|
+
await this.supervisor.stopSession(id);
|
|
3291
|
+
void this.supervisor.start(id).catch(() => {});
|
|
3292
|
+
this.send(res, 200, this.supervisor.inspect(id));
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
2833
3295
|
if (parts[3] === 'resume' && req.method === 'POST') {
|
|
2834
3296
|
const body = object(await readJson(req));
|
|
2835
3297
|
this.store.authorizeControl(id, {
|
|
@@ -2848,6 +3310,12 @@ export class SalaamService {
|
|
|
2848
3310
|
);
|
|
2849
3311
|
if (session.state !== 'ready' && session.state !== 'starting')
|
|
2850
3312
|
void this.supervisor.start(id).catch(() => {});
|
|
3313
|
+
// Show the project instructions an interactive session will use before its first turn.
|
|
3314
|
+
try {
|
|
3315
|
+
this.tasks.instructions?.refresh(id);
|
|
3316
|
+
} catch {
|
|
3317
|
+
/* reported on the next turn */
|
|
3318
|
+
}
|
|
2851
3319
|
this.send(res, 202, this.supervisor.inspect(id));
|
|
2852
3320
|
return;
|
|
2853
3321
|
}
|
|
@@ -2863,11 +3331,24 @@ export class SalaamService {
|
|
|
2863
3331
|
}
|
|
2864
3332
|
if (parts[3] === 'submissions' && req.method === 'POST') {
|
|
2865
3333
|
const body = object(await readJson(req));
|
|
3334
|
+
const local = this.store.localCoding(id);
|
|
3335
|
+
let text = string(body.text, 'text', 256 * 1024);
|
|
3336
|
+
let images: PromptImage[] = [];
|
|
3337
|
+
// Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
|
|
3338
|
+
if (local) {
|
|
3339
|
+
if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
|
|
3340
|
+
throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
|
|
3341
|
+
({ text, images } = expandPrompt(
|
|
3342
|
+
this.store.session(id).cwd,
|
|
3343
|
+
text,
|
|
3344
|
+
body.confirm_sensitive === true,
|
|
3345
|
+
));
|
|
3346
|
+
}
|
|
2866
3347
|
const submission = this.store.accept(
|
|
2867
3348
|
id,
|
|
2868
3349
|
string(body.request_id, 'request_id', 128),
|
|
2869
3350
|
integer(body.expected_revision, 'expected_revision'),
|
|
2870
|
-
|
|
3351
|
+
text,
|
|
2871
3352
|
{
|
|
2872
3353
|
client_id: string(body.client_id, 'client_id', 128),
|
|
2873
3354
|
token: string(body.token, 'token', 128),
|
|
@@ -2876,6 +3357,8 @@ export class SalaamService {
|
|
|
2876
3357
|
? null
|
|
2877
3358
|
: string(body.continues_attempt_id, 'continues_attempt_id', 128),
|
|
2878
3359
|
);
|
|
3360
|
+
// Same tick as the acceptance, so dispatch cannot claim the submission without them.
|
|
3361
|
+
storeImages(this.store.db, submission.id, images);
|
|
2879
3362
|
this.send(res, 202, submission);
|
|
2880
3363
|
return;
|
|
2881
3364
|
}
|