@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/dist/src/service.js
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 } from 'node:http';
|
|
2
5
|
import { loadPersona } from './persona.js';
|
|
3
6
|
import { mkdirSync, chmodSync, chownSync, existsSync, unlinkSync, realpathSync, statSync, } from 'node:fs';
|
|
@@ -5,6 +8,7 @@ import { join, resolve } from 'node:path';
|
|
|
5
8
|
import { providerRoute } from './provider-config.js';
|
|
6
9
|
import { TaskContext } from './task-context.js';
|
|
7
10
|
import { ContextAdmission } from './context-admission.js';
|
|
11
|
+
import { ProjectInstructions, userInstructionsPath } from './project-instructions.js';
|
|
8
12
|
import { SLASH_COMMANDS } from './tui-commands.js';
|
|
9
13
|
import { TaskWork } from './task-work.js';
|
|
10
14
|
import { TaskBudget } from './task-budget.js';
|
|
@@ -20,6 +24,7 @@ import { OperatorInbox } from './operator-inbox.js';
|
|
|
20
24
|
import { RecurringWork } from './recurring-work.js';
|
|
21
25
|
import { JobWork } from './job-work.js';
|
|
22
26
|
import { Store } from './store.js';
|
|
27
|
+
import { pickerSummary } from './session-pick.js';
|
|
23
28
|
import { acquireStateLock } from './state-lock.js';
|
|
24
29
|
import { Artifacts } from './artifacts.js';
|
|
25
30
|
import { Supervisor } from './supervisor.js';
|
|
@@ -53,11 +58,15 @@ import { McpAdmission } from './mcp-admission.js';
|
|
|
53
58
|
import { McpWork } from './mcp-work.js';
|
|
54
59
|
import { ToolkitWork } from './toolkit-work.js';
|
|
55
60
|
import { permissionPolicy, configurePermissions } from './autonomy.js';
|
|
56
|
-
import {
|
|
61
|
+
import { expandPrompt, storeImages } from './mentions.js';
|
|
62
|
+
import { ApprovalRules } from './approval-rules.js';
|
|
63
|
+
import { chooseLocalSandbox, resolveReadPaths } from './command-sandbox.js';
|
|
57
64
|
import { LocalCoding } from './local-coding.js';
|
|
65
|
+
import { Hooks } from './hooks.js';
|
|
58
66
|
import { localDiff } from './local-diff.js';
|
|
59
67
|
import { reviewUndo, applyUndo, reconcileUndo } from './local-undo.js';
|
|
60
68
|
import { forkPiSession } from './pi-sessions.js';
|
|
69
|
+
import { TurnCheckpoints } from './turn-rewind.js';
|
|
61
70
|
import { StationWork } from './station-work.js';
|
|
62
71
|
import { readToolkitConfig } from './toolkit-config.js';
|
|
63
72
|
import { probeToolsUser, serviceToolIdentity } from './tool-identity.js';
|
|
@@ -115,7 +124,25 @@ export class SalaamService {
|
|
|
115
124
|
hostReceipt;
|
|
116
125
|
operatorAccess;
|
|
117
126
|
agentpodWorkProgression;
|
|
127
|
+
/** Phase 2b: whether interactive sessions here may choose full access (no OS sandbox). */
|
|
128
|
+
allowFullAccessSessions;
|
|
129
|
+
allowYoloSessions;
|
|
130
|
+
reviewers = new Map();
|
|
131
|
+
reviewerDefault = 'off';
|
|
132
|
+
reviewerPin;
|
|
133
|
+
/** The reviewer a session uses: the host's pin, else its own choice, else the config default. */
|
|
134
|
+
reviewerChoice(session) {
|
|
135
|
+
if (this.reviewerPin)
|
|
136
|
+
return this.reviewerPin;
|
|
137
|
+
const row = this.store.db
|
|
138
|
+
.prepare('SELECT choice FROM danger_classifier_choice WHERE session_id=?')
|
|
139
|
+
.get(session);
|
|
140
|
+
const choice = row?.choice ?? this.reviewerDefault;
|
|
141
|
+
return choice === 'off' || this.reviewers.has(choice) ? choice : 'off';
|
|
142
|
+
}
|
|
118
143
|
constructor(options) {
|
|
144
|
+
this.allowFullAccessSessions = options.allowFullAccessSessions !== false;
|
|
145
|
+
this.allowYoloSessions = options.allowYoloSessions !== false;
|
|
119
146
|
this.agentpodWorkProgression = options.agentpodWorkProgression === true;
|
|
120
147
|
if (this.agentpodWorkProgression && (!options.stationConfig || !options.agentpodConfig))
|
|
121
148
|
throw Error('Strict work requires reviewed AgentPod protected station configuration');
|
|
@@ -157,7 +184,8 @@ export class SalaamService {
|
|
|
157
184
|
this.socket = join(this.stateDir, 'operator.sock');
|
|
158
185
|
this.bridgeSocket = join(this.stateDir, 'bridge.sock');
|
|
159
186
|
if (Buffer.byteLength(this.bridgeSocket) > 100)
|
|
160
|
-
throw new Error(
|
|
187
|
+
throw new Error(`State path is too long for a Unix socket (${Buffer.byteLength(this.bridgeSocket)} bytes; the limit is 100): ` +
|
|
188
|
+
'use the default (salaam code, no --state), or a shorter --state or SALAAM_STATE_DIR such as /tmp/salaam-state.');
|
|
161
189
|
this.release = acquireStateLock(this.stateDir);
|
|
162
190
|
try {
|
|
163
191
|
this.store = new Store(join(this.stateDir, 'journal.sqlite'));
|
|
@@ -170,6 +198,10 @@ export class SalaamService {
|
|
|
170
198
|
this.artifacts = new Artifacts(join(this.stateDir, 'artifacts'), this.store);
|
|
171
199
|
this.tasks = new TaskContext(this.store, this.artifacts);
|
|
172
200
|
this.tasks.admitted = new ContextAdmission(this.store);
|
|
201
|
+
this.tasks.instructions = new ProjectInstructions(this.store, this.artifacts, options.userInstructions === undefined
|
|
202
|
+
? userInstructionsPath()
|
|
203
|
+
: options.userInstructions);
|
|
204
|
+
this.tasks.checkpoints = new TurnCheckpoints(this.store, this.stateDir, this.toolIdentity.mode !== 'tools-user');
|
|
173
205
|
this.taskWork = new TaskWork(this.store, this.tasks);
|
|
174
206
|
this.taskBudget = new TaskBudget(this.store, this.taskWork);
|
|
175
207
|
if (options.admitModelRequest) {
|
|
@@ -252,10 +284,43 @@ export class SalaamService {
|
|
|
252
284
|
const localSandbox = {
|
|
253
285
|
...chooseLocalSandbox(options.localSandbox ?? 'workspace-write'),
|
|
254
286
|
allowUnsandboxed: options.unsandboxedCommands === 'allow',
|
|
287
|
+
...(options.localSandboxReadPaths?.length
|
|
288
|
+
? { readPaths: resolveReadPaths(options.localSandboxReadPaths) }
|
|
289
|
+
: {}),
|
|
255
290
|
...(options.commandNetwork ? { network: { socket: options.commandNetwork.socket } } : {}),
|
|
256
291
|
};
|
|
257
292
|
this.localCoding = new LocalCoding(this.store, this.managed, (id, epoch, attempt) => this.supervisor.assertActive(id, epoch, attempt), this.toolIdentity, localSandbox);
|
|
293
|
+
this.localCoding.rules = new ApprovalRules(this.store, options.permissionsFile);
|
|
294
|
+
this.localCoding.interactive = (id) => this.supervisor.isInteractiveCoding(id);
|
|
295
|
+
this.localCoding.allowOutsideSandbox = this.allowFullAccessSessions;
|
|
258
296
|
this.supervisor.localCoding = this.localCoding;
|
|
297
|
+
if (options.dangerClassifierConfig) {
|
|
298
|
+
const loaded = loadDangerClassifierOptions(JSON.parse(readFileSync(options.dangerClassifierConfig, 'utf8')));
|
|
299
|
+
for (const o of loaded.options)
|
|
300
|
+
this.reviewers.set(o.name, new DangerClassifier(o));
|
|
301
|
+
this.reviewerDefault = loaded.default;
|
|
302
|
+
}
|
|
303
|
+
if (options.dangerClassifierPin !== undefined) {
|
|
304
|
+
if (options.dangerClassifierPin !== 'off' &&
|
|
305
|
+
!this.reviewers.has(options.dangerClassifierPin))
|
|
306
|
+
throw Error(`The pinned command reviewer ${options.dangerClassifierPin} is not configured`);
|
|
307
|
+
this.reviewerPin = options.dangerClassifierPin;
|
|
308
|
+
}
|
|
309
|
+
if (this.reviewers.size) {
|
|
310
|
+
this.store.db.exec('CREATE TABLE IF NOT EXISTS danger_classifier_choice(session_id TEXT PRIMARY KEY, choice TEXT NOT NULL)');
|
|
311
|
+
this.localCoding.classifier = {
|
|
312
|
+
review: (s, command, cwd) => {
|
|
313
|
+
const reviewer = this.reviewers.get(this.reviewerChoice(s));
|
|
314
|
+
return reviewer ? reviewer.review(s, command, cwd) : Promise.resolve(null);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const local = this.localCoding;
|
|
319
|
+
this.supervisor.hooks = new Hooks(this.store, (session, command, input, timeout) => local.runHook(session, command, input, timeout), options.hooksFile);
|
|
320
|
+
const webConfig = options.webSearchConfig
|
|
321
|
+
? loadWebSearchConfig(options.webSearchConfig)
|
|
322
|
+
: { providers: [] };
|
|
323
|
+
this.supervisor.web = new WebSearch(webConfig);
|
|
259
324
|
this.agents = new AgentProfiles(this.store, this.supervisor.route, async (route, session) => {
|
|
260
325
|
const prepared = await this.supervisor.prepareProviderRoute(route);
|
|
261
326
|
const task = this.taskWork.forSession(session);
|
|
@@ -1298,7 +1363,10 @@ export class SalaamService {
|
|
|
1298
1363
|
return;
|
|
1299
1364
|
}
|
|
1300
1365
|
if (url.pathname === '/v1/sessions' && req.method === 'GET') {
|
|
1301
|
-
this.send(res, 200, this.store.sessions().map((x) =>
|
|
1366
|
+
this.send(res, 200, this.store.sessions().map((x) => {
|
|
1367
|
+
const session = this.supervisor.inspect(x.id);
|
|
1368
|
+
return { ...session, ...pickerSummary(session, this.store) };
|
|
1369
|
+
}));
|
|
1302
1370
|
return;
|
|
1303
1371
|
}
|
|
1304
1372
|
if (url.pathname === '/v1/sessions' && req.method === 'POST') {
|
|
@@ -1389,13 +1457,52 @@ export class SalaamService {
|
|
|
1389
1457
|
}
|
|
1390
1458
|
if (parts[0] === 'v1' && parts[1] === 'sessions' && parts[2]) {
|
|
1391
1459
|
const id = parts[2];
|
|
1460
|
+
// Desk-session approval rules (phase 2a): list, add (from a reviewed operation or written out),
|
|
1461
|
+
// and remove. Adding or removing needs the session's control lease, like the permission policy.
|
|
1462
|
+
if (parts[3] === 'approval-rules' && (parts.length === 4 || parts.length === 6)) {
|
|
1463
|
+
const rules = this.localCoding.rules;
|
|
1464
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
1465
|
+
this.send(res, 200, {
|
|
1466
|
+
applies: rules.applies(id),
|
|
1467
|
+
rules: rules.list(id),
|
|
1468
|
+
problems: rules.problems(id),
|
|
1469
|
+
});
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const body = object(await readJson(req));
|
|
1473
|
+
this.store.authorizeControl(id, {
|
|
1474
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1475
|
+
token: string(body.token, 'token', 128),
|
|
1476
|
+
});
|
|
1477
|
+
if (parts.length === 4 && req.method === 'POST') {
|
|
1478
|
+
const scope = string(body.scope, 'scope');
|
|
1479
|
+
if (scope !== 'session' && scope !== 'repo')
|
|
1480
|
+
throw new ServiceError('invalid_field', 'A rule scope is session or repo', 400);
|
|
1481
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
1482
|
+
this.send(res, 200, body.operation_id !== undefined
|
|
1483
|
+
? rules.addFromOperation(id, string(body.operation_id, 'operation_id', 128), scope, request)
|
|
1484
|
+
: rules.add(id, scope, body.rule, request));
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (parts.length === 6 && parts[5] === 'remove' && req.method === 'POST') {
|
|
1488
|
+
rules.remove(id, string(parts[4], 'rule', 128));
|
|
1489
|
+
this.send(res, 200, { removed: parts[4] });
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1392
1493
|
if (parts.length === 4 && parts[3] === 'permissions') {
|
|
1393
1494
|
if (req.method === 'GET') {
|
|
1394
1495
|
this.send(res, 200, {
|
|
1395
1496
|
policy: permissionPolicy(this.store, id),
|
|
1396
1497
|
configurable: this.store.localWorkspace(id),
|
|
1397
1498
|
coding: this.store.localCoding(id),
|
|
1398
|
-
|
|
1499
|
+
// Desk coding sessions: approval rules and auto mode can approve commands (Phase 2a/2b).
|
|
1500
|
+
command_auto_approval: this.store.localCoding(id),
|
|
1501
|
+
full_access_allowed: this.allowFullAccessSessions,
|
|
1502
|
+
yolo_allowed: this.allowYoloSessions && !!this.localCoding.rules?.applies(id),
|
|
1503
|
+
...(this.store.localCoding(id)
|
|
1504
|
+
? { command_sandbox: this.localCoding.commandSandbox(id) }
|
|
1505
|
+
: {}),
|
|
1399
1506
|
});
|
|
1400
1507
|
return;
|
|
1401
1508
|
}
|
|
@@ -1404,16 +1511,128 @@ export class SalaamService {
|
|
|
1404
1511
|
this.send(res, 200, configurePermissions(this.store, id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), body.policy, {
|
|
1405
1512
|
client_id: string(body.client_id, 'client_id', 128),
|
|
1406
1513
|
token: string(body.token, 'token', 128),
|
|
1514
|
+
}, {
|
|
1515
|
+
allowFullAccess: this.allowFullAccessSessions,
|
|
1516
|
+
allowYolo: this.allowYoloSessions,
|
|
1517
|
+
deskSession: !!this.localCoding.rules?.applies(id),
|
|
1407
1518
|
}));
|
|
1408
1519
|
return;
|
|
1409
1520
|
}
|
|
1410
1521
|
}
|
|
1522
|
+
if (parts.length === 4 && parts[3] === 'danger-classifier') {
|
|
1523
|
+
const view = () => {
|
|
1524
|
+
const choice = this.reviewers.size ? this.reviewerChoice(id) : 'off', chosen = this.reviewers.get(choice)?.config;
|
|
1525
|
+
return {
|
|
1526
|
+
configured: this.reviewers.size > 0,
|
|
1527
|
+
enabled: !!chosen,
|
|
1528
|
+
...(chosen
|
|
1529
|
+
? {
|
|
1530
|
+
provider: chosen.provider,
|
|
1531
|
+
model: chosen.model,
|
|
1532
|
+
on_unavailable: chosen.on_unavailable,
|
|
1533
|
+
}
|
|
1534
|
+
: {}),
|
|
1535
|
+
choice,
|
|
1536
|
+
pinned: this.reviewerPin !== undefined,
|
|
1537
|
+
options: [...this.reviewers.values()].map(({ config: o }) => ({
|
|
1538
|
+
name: o.name,
|
|
1539
|
+
provider: o.provider,
|
|
1540
|
+
model: o.model,
|
|
1541
|
+
})),
|
|
1542
|
+
};
|
|
1543
|
+
};
|
|
1544
|
+
if (req.method === 'GET') {
|
|
1545
|
+
this.send(res, 200, view());
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (req.method === 'POST') {
|
|
1549
|
+
const body = object(await readJson(req));
|
|
1550
|
+
this.store.authorizeControl(id, {
|
|
1551
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1552
|
+
token: string(body.token, 'token', 128),
|
|
1553
|
+
});
|
|
1554
|
+
// `enabled` is the earlier on/off form: on means the config's default.
|
|
1555
|
+
const choice = typeof body.enabled === 'boolean'
|
|
1556
|
+
? body.enabled
|
|
1557
|
+
? this.reviewerDefault === 'off'
|
|
1558
|
+
? [...this.reviewers.keys()][0]
|
|
1559
|
+
: this.reviewerDefault
|
|
1560
|
+
: 'off'
|
|
1561
|
+
: string(body.choice, 'choice', 64);
|
|
1562
|
+
if (!this.reviewers.size && choice !== 'off')
|
|
1563
|
+
throw new ServiceError('classifier_unavailable', 'No command reviewer is configured on this host (--danger-classifier-config)');
|
|
1564
|
+
if (this.reviewerPin !== undefined)
|
|
1565
|
+
throw new ServiceError('classifier_pinned', `This host pinned the command reviewer (${this.reviewerPin}); sessions cannot change it`, 403);
|
|
1566
|
+
if (choice !== 'off' && !this.reviewers.has(choice ?? ''))
|
|
1567
|
+
throw new ServiceError('invalid_field', `Unknown reviewer ${choice}`);
|
|
1568
|
+
if (this.reviewers.size)
|
|
1569
|
+
this.store.transaction(() => {
|
|
1570
|
+
this.store.db
|
|
1571
|
+
.prepare('INSERT INTO danger_classifier_choice(session_id,choice) VALUES(?,?) ON CONFLICT(session_id) DO UPDATE SET choice=excluded.choice')
|
|
1572
|
+
.run(id, choice);
|
|
1573
|
+
this.store.event(id, 'permission.classifier-set', { choice });
|
|
1574
|
+
});
|
|
1575
|
+
this.send(res, 200, view());
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
// Desk-session hooks (Phase 4b): list, trust the reviewed repository file, or revoke it.
|
|
1580
|
+
if (parts[3] === 'hooks' && this.supervisor.hooks) {
|
|
1581
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
1582
|
+
throw new ServiceError('hooks_denied', 'Hooks run only in your own desk sessions', 403);
|
|
1583
|
+
const hooks = this.supervisor.hooks;
|
|
1584
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
1585
|
+
this.send(res, 200, { sources: hooks.sources(id) });
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
if (parts.length === 5 && req.method === 'POST') {
|
|
1589
|
+
const body = object(await readJson(req));
|
|
1590
|
+
this.store.authorizeControl(id, {
|
|
1591
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1592
|
+
token: string(body.token, 'token', 128),
|
|
1593
|
+
});
|
|
1594
|
+
if (parts[4] === 'trust') {
|
|
1595
|
+
this.send(res, 200, hooks.trust(id, string(body.digest, 'digest', 128)));
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
if (parts[4] === 'revoke') {
|
|
1599
|
+
hooks.revoke(id);
|
|
1600
|
+
this.send(res, 200, { revoked: true });
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
if (parts[3] === 'local-tasks' && this.localCoding) {
|
|
1606
|
+
if (!this.supervisor.isInteractiveCoding(id))
|
|
1607
|
+
throw new ServiceError('background_denied', 'Background commands are not admitted', 403);
|
|
1608
|
+
if (parts.length === 4 && req.method === 'GET') {
|
|
1609
|
+
this.send(res, 200, { tasks: this.localCoding.tasks(id) });
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (parts.length === 5 && req.method === 'GET') {
|
|
1613
|
+
this.send(res, 200, this.localCoding.taskOutput(id, parts[4]));
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
if (parts.length === 6 && parts[5] === 'stop' && req.method === 'POST') {
|
|
1617
|
+
this.send(res, 200, this.localCoding.stopTask(id, parts[4]));
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1411
1621
|
if (parts.length === 4 && parts[3] === 'local-diff' && req.method === 'GET') {
|
|
1412
1622
|
if (!this.store.localWorkspace(id))
|
|
1413
1623
|
throw new ServiceError('local_denied', 'Local workspace required');
|
|
1414
1624
|
this.send(res, 200, { text: await localDiff(this.store.session(id).cwd) });
|
|
1415
1625
|
return;
|
|
1416
1626
|
}
|
|
1627
|
+
// An edit request's exact files, before and after, so an editor can show it as a diff.
|
|
1628
|
+
if (parts.length === 4 && parts[3] === 'local-edits' && req.method === 'GET') {
|
|
1629
|
+
if (!this.store.localCoding(id))
|
|
1630
|
+
throw new ServiceError('local_denied', 'Local coding session required');
|
|
1631
|
+
this.send(res, 200, this.localCoding.edits(id, url.searchParams.has('operation_id')
|
|
1632
|
+
? { operation: string(url.searchParams.get('operation_id'), 'operation_id', 128) }
|
|
1633
|
+
: { toolCall: string(url.searchParams.get('tool_call'), 'tool_call', 512) }));
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1417
1636
|
if (parts.length === 4 && parts[3] === 'steer' && req.method === 'POST') {
|
|
1418
1637
|
const body = object(await readJson(req));
|
|
1419
1638
|
this.send(res, 200, await this.supervisor.steer(id, string(body.request_id, 'request_id', 128), string(body.text, 'text', 65536), {
|
|
@@ -1450,6 +1669,83 @@ export class SalaamService {
|
|
|
1450
1669
|
return;
|
|
1451
1670
|
}
|
|
1452
1671
|
}
|
|
1672
|
+
if ((parts.length === 4 || parts.length === 5) &&
|
|
1673
|
+
(parts[3] === 'checkpoints' || parts[3] === 'rewind' || parts[3] === 'review')) {
|
|
1674
|
+
const checkpoints = this.tasks.checkpoints;
|
|
1675
|
+
// Operator ruling 2026-10-31: background commands may keep writing; say so, do not block.
|
|
1676
|
+
const running = (value) => value && this.localCoding
|
|
1677
|
+
? {
|
|
1678
|
+
...value,
|
|
1679
|
+
background_running: this.localCoding
|
|
1680
|
+
.tasks(id)
|
|
1681
|
+
.filter((t) => t.state === 'running').length,
|
|
1682
|
+
}
|
|
1683
|
+
: value;
|
|
1684
|
+
const controller = (body) => ({
|
|
1685
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
1686
|
+
token: string(body.token, 'token', 128),
|
|
1687
|
+
});
|
|
1688
|
+
const idle = () => {
|
|
1689
|
+
this.supervisor.assertIdle(id);
|
|
1690
|
+
if (this.store.db
|
|
1691
|
+
.prepare("SELECT 1 FROM attempts WHERE session_id=? AND state IN ('accepted','dispatching','running')")
|
|
1692
|
+
.get(id))
|
|
1693
|
+
throw new ServiceError('session_busy', 'Wait for the current turn to finish');
|
|
1694
|
+
};
|
|
1695
|
+
if (parts[3] === 'checkpoints' && parts.length === 4 && req.method === 'GET') {
|
|
1696
|
+
this.send(res, 200, { checkpoints: checkpoints.list(id) });
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'GET') {
|
|
1700
|
+
this.send(res, 200, running(checkpoints.reviewRewind(id, string(url.searchParams.get('attempt'), 'attempt', 128), string(url.searchParams.get('mode'), 'mode', 16))));
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (parts[3] === 'rewind' && parts.length === 4 && req.method === 'POST') {
|
|
1704
|
+
if (this.agentpod)
|
|
1705
|
+
throw new ServiceError('admission_required', 'Managed station sessions cannot rewind');
|
|
1706
|
+
const body = object(await readJson(req));
|
|
1707
|
+
const request = string(body.request_id, 'request_id', 128);
|
|
1708
|
+
this.send(res, 200, checkpoints.rewind(id, request, string(body.attempt_id, 'attempt_id', 128), string(body.mode, 'mode', 16), string(body.review_digest, 'review_digest', 128), controller(body), idle, (leaf, summary) => this.store.forkLocal(id, request + ':fork', this.store.session(id).revision, controller(body), (source, target) => {
|
|
1709
|
+
const dir = join(this.stateDir, 'workers', target, 'sessions');
|
|
1710
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
1711
|
+
return forkPiSession(source.pi_session_file, source.cwd, dir, target, {
|
|
1712
|
+
leaf,
|
|
1713
|
+
summary,
|
|
1714
|
+
});
|
|
1715
|
+
}, { leaf })));
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'GET') {
|
|
1719
|
+
this.send(res, 200, { review: running(checkpoints.review(id)) });
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
if (parts[3] === 'review' && parts[4] === 'hunks' && req.method === 'GET') {
|
|
1723
|
+
const q = (name) => string(url.searchParams.get(name), name, 1024);
|
|
1724
|
+
this.send(res, 200, {
|
|
1725
|
+
hunks: checkpoints.hunks(id, q('path'), q('base'), q('current')),
|
|
1726
|
+
});
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (parts[3] === 'review' && parts.length === 4 && req.method === 'POST') {
|
|
1730
|
+
const body = object(await readJson(req));
|
|
1731
|
+
const hunk = body.hunk === undefined
|
|
1732
|
+
? undefined
|
|
1733
|
+
: {
|
|
1734
|
+
text: string(object(body.hunk).text, 'hunk.text', 256 * 1024),
|
|
1735
|
+
patch: string(object(body.hunk).patch, 'hunk.patch', 256 * 1024),
|
|
1736
|
+
};
|
|
1737
|
+
this.send(res, 200, {
|
|
1738
|
+
review: running(checkpoints.act(id, {
|
|
1739
|
+
action: string(body.action, 'action', 16),
|
|
1740
|
+
path: string(body.path, 'path', 1024),
|
|
1741
|
+
...(hunk ? { hunk } : {}),
|
|
1742
|
+
base: string(body.base, 'base', 128),
|
|
1743
|
+
current: string(body.current, 'current', 128),
|
|
1744
|
+
}, controller(body), idle)),
|
|
1745
|
+
});
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1453
1749
|
if (parts.length === 4 && parts[3] === 'fork' && req.method === 'POST') {
|
|
1454
1750
|
if (this.agentpod)
|
|
1455
1751
|
throw new ServiceError('admission_required', 'Managed station sessions cannot be forked locally');
|
|
@@ -1845,6 +2141,29 @@ export class SalaamService {
|
|
|
1845
2141
|
this.send(res, 200, this.store.control(id, string(body.request_id, 'request_id', 128), string(body.client_id, 'client_id', 128), action, integer(body.expected_epoch, 'expected_epoch'), body.token === undefined ? undefined : string(body.token, 'token', 128)));
|
|
1846
2142
|
return;
|
|
1847
2143
|
}
|
|
2144
|
+
if (parts[3] === 'route' && req.method === 'POST') {
|
|
2145
|
+
const body = object(await readJson(req));
|
|
2146
|
+
this.store.authorizeControl(id, {
|
|
2147
|
+
client_id: string(body.client_id, 'client_id', 128),
|
|
2148
|
+
token: string(body.token, 'token', 128),
|
|
2149
|
+
});
|
|
2150
|
+
if (!this.tasks.instructions.applies(id))
|
|
2151
|
+
throw new ServiceError('route_switch_unavailable', 'Only interactive sessions can switch model mid-session', 409);
|
|
2152
|
+
this.supervisor.assertIdle(id);
|
|
2153
|
+
const input = object(body.route);
|
|
2154
|
+
const requested = providerRoute(string(input.provider, 'provider'), input.model === undefined ? undefined : string(input.model, 'model'), undefined, undefined, undefined, input.reasoning);
|
|
2155
|
+
const prepared = await this.supervisor.prepareProviderRoute(requested).catch(() => {
|
|
2156
|
+
throw new ServiceError('provider_auth_required', `Configure ${requested.provider} with sal auth login ${requested.provider} or sal auth set ${requested.provider}, then retry.`, 400);
|
|
2157
|
+
});
|
|
2158
|
+
if (prepared.output_cap !== (this.store.localOutputCap(id) ?? true))
|
|
2159
|
+
throw new ServiceError('provider_output_cap_changed', 'That route enforces output caps differently; start a new session for it', 409);
|
|
2160
|
+
this.supervisor.assertIdle(id);
|
|
2161
|
+
this.store.switchRoute(id, prepared.route);
|
|
2162
|
+
await this.supervisor.stopSession(id);
|
|
2163
|
+
void this.supervisor.start(id).catch(() => { });
|
|
2164
|
+
this.send(res, 200, this.supervisor.inspect(id));
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
1848
2167
|
if (parts[3] === 'resume' && req.method === 'POST') {
|
|
1849
2168
|
const body = object(await readJson(req));
|
|
1850
2169
|
this.store.authorizeControl(id, {
|
|
@@ -1858,6 +2177,13 @@ export class SalaamService {
|
|
|
1858
2177
|
throw new ServiceError('provider_route_changed', 'Restart the service with this session’s original provider route, or create a new session');
|
|
1859
2178
|
if (session.state !== 'ready' && session.state !== 'starting')
|
|
1860
2179
|
void this.supervisor.start(id).catch(() => { });
|
|
2180
|
+
// Show the project instructions an interactive session will use before its first turn.
|
|
2181
|
+
try {
|
|
2182
|
+
this.tasks.instructions?.refresh(id);
|
|
2183
|
+
}
|
|
2184
|
+
catch {
|
|
2185
|
+
/* reported on the next turn */
|
|
2186
|
+
}
|
|
1861
2187
|
this.send(res, 202, this.supervisor.inspect(id));
|
|
1862
2188
|
return;
|
|
1863
2189
|
}
|
|
@@ -1873,12 +2199,23 @@ export class SalaamService {
|
|
|
1873
2199
|
}
|
|
1874
2200
|
if (parts[3] === 'submissions' && req.method === 'POST') {
|
|
1875
2201
|
const body = object(await readJson(req));
|
|
1876
|
-
const
|
|
2202
|
+
const local = this.store.localCoding(id);
|
|
2203
|
+
let text = string(body.text, 'text', 256 * 1024);
|
|
2204
|
+
let images = [];
|
|
2205
|
+
// Desk prompts attach their @mentions (Phase 3b); board work never reads the tree here.
|
|
2206
|
+
if (local) {
|
|
2207
|
+
if (body.confirm_sensitive !== undefined && typeof body.confirm_sensitive !== 'boolean')
|
|
2208
|
+
throw new ServiceError('invalid_field', 'Invalid confirm_sensitive flag', 400);
|
|
2209
|
+
({ text, images } = expandPrompt(this.store.session(id).cwd, text, body.confirm_sensitive === true));
|
|
2210
|
+
}
|
|
2211
|
+
const submission = this.store.accept(id, string(body.request_id, 'request_id', 128), integer(body.expected_revision, 'expected_revision'), text, {
|
|
1877
2212
|
client_id: string(body.client_id, 'client_id', 128),
|
|
1878
2213
|
token: string(body.token, 'token', 128),
|
|
1879
2214
|
}, body.continues_attempt_id === undefined
|
|
1880
2215
|
? null
|
|
1881
2216
|
: string(body.continues_attempt_id, 'continues_attempt_id', 128));
|
|
2217
|
+
// Same tick as the acceptance, so dispatch cannot claim the submission without them.
|
|
2218
|
+
storeImages(this.store.db, submission.id, images);
|
|
1882
2219
|
this.send(res, 202, submission);
|
|
1883
2220
|
return;
|
|
1884
2221
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { typedText } from './shell-passthrough.js';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import { OFFLINE_ROUTE } from './provider-config.js';
|
|
4
|
+
export function parseCodeStart(flags) {
|
|
5
|
+
const chosen = ['continue', 'new', 'resume'].filter((f) => flags.includes('--' + f));
|
|
6
|
+
if (chosen.length > 1)
|
|
7
|
+
throw new Error('Choose one of --continue, --new or --resume.');
|
|
8
|
+
return chosen[0] ?? 'continue';
|
|
9
|
+
}
|
|
10
|
+
const folder = (path) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
|
|
11
|
+
/** The most recently created session in this folder; sessions come oldest first. */
|
|
12
|
+
export function latestSessionFor(sessions, cwd) {
|
|
13
|
+
const want = folder(cwd);
|
|
14
|
+
return [...sessions].reverse().find((s) => folder(s.cwd) === want)?.id;
|
|
15
|
+
}
|
|
16
|
+
/** Whether a new `salaam code` session should use Anthropic: only when nothing was chosen, the
|
|
17
|
+
* running service defaults to offline (as a TUI-started one does) and a key is stored. */
|
|
18
|
+
export function deskUsesAnthropic(chosen, serviceProvider, hasAnthropicKey) {
|
|
19
|
+
return (!chosen &&
|
|
20
|
+
(serviceProvider ?? OFFLINE_ROUTE.provider) === OFFLINE_ROUTE.provider &&
|
|
21
|
+
hasAnthropicKey);
|
|
22
|
+
}
|
|
23
|
+
/** `salaam -c` and `salaam --continue` are short for `salaam code --continue`. */
|
|
24
|
+
export function continueArgs(args) {
|
|
25
|
+
if (args[0] !== '-c' && args[0] !== '--continue')
|
|
26
|
+
return;
|
|
27
|
+
return ['code', '--continue', ...args.slice(1)];
|
|
28
|
+
}
|
|
29
|
+
const activeAt = (s) => s.last_active_at ? Date.parse(s.last_active_at) : -Infinity;
|
|
30
|
+
/** This folder's sessions first, then every other one; newest first within each. */
|
|
31
|
+
export function pickerOrder(sessions, cwd) {
|
|
32
|
+
const want = folder(cwd);
|
|
33
|
+
const newest = (a, b) => activeAt(b) - activeAt(a);
|
|
34
|
+
const here = sessions.filter((s) => folder(s.cwd) === want).sort(newest);
|
|
35
|
+
return [...here, ...sessions.filter((s) => folder(s.cwd) !== want).sort(newest)];
|
|
36
|
+
}
|
|
37
|
+
export function relativeTime(at, now = Date.now()) {
|
|
38
|
+
if (!at)
|
|
39
|
+
return '';
|
|
40
|
+
const s = Math.max(0, (now - Date.parse(at)) / 1000);
|
|
41
|
+
if (s < 60)
|
|
42
|
+
return 'just now';
|
|
43
|
+
if (s < 3600)
|
|
44
|
+
return `${Math.floor(s / 60)} min ago`;
|
|
45
|
+
if (s < 86400)
|
|
46
|
+
return `${Math.floor(s / 3600)} h ago`;
|
|
47
|
+
if (s < 2 * 86400)
|
|
48
|
+
return 'yesterday';
|
|
49
|
+
return `${Math.floor(s / 86400)} days ago`;
|
|
50
|
+
}
|
|
51
|
+
/** One picker row: what you last asked (one short line), when, and where. */
|
|
52
|
+
export function sessionDescription(s, now = Date.now()) {
|
|
53
|
+
const prompt = s.last_prompt?.replace(/\s+/g, ' ').trim();
|
|
54
|
+
const quoted = prompt ? `“${prompt.length > 60 ? prompt.slice(0, 59) + '…' : prompt}”` : '';
|
|
55
|
+
return [quoted || s.state, relativeTime(s.last_active_at, now), s.cwd]
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(' · ');
|
|
58
|
+
}
|
|
59
|
+
/** What the picker shows for a desk session: its newest prompt (at most 200 characters) and when
|
|
60
|
+
* its transcript last changed. Board and station sessions get nothing extra. */
|
|
61
|
+
export function pickerSummary(session, store) {
|
|
62
|
+
if (!session.capabilities.includes('local-coding-reviewed'))
|
|
63
|
+
return {};
|
|
64
|
+
const prompt = store.lastPrompt(session.id);
|
|
65
|
+
let at;
|
|
66
|
+
try {
|
|
67
|
+
if (session.pi_session_file)
|
|
68
|
+
at = statSync(session.pi_session_file).mtime.toISOString();
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* no transcript yet */
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...(prompt ? { last_prompt: typedText(prompt).slice(0, 200) } : {}),
|
|
75
|
+
...(at ? { last_active_at: at } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `!command` in a desk session (trial polish #12, 2026-10-31). The terminal never runs the command
|
|
3
|
+
* itself: it asks the model to run exactly that text through salaam_local. The service reads the
|
|
4
|
+
* same fixed prompt back from the turn's submission and binds the turn to the typed command, so a
|
|
5
|
+
* model that runs anything else is refused, whatever the session's mode.
|
|
6
|
+
*/
|
|
7
|
+
const LEAD = 'I want to run this shell command myself. Run exactly this command with salaam_local';
|
|
8
|
+
export function shellPassthrough(text) {
|
|
9
|
+
const t = text.trim();
|
|
10
|
+
if (!t.startsWith('!') || t.startsWith('!!'))
|
|
11
|
+
return undefined;
|
|
12
|
+
const command = t.slice(1).trim();
|
|
13
|
+
if (command === '' || command.includes('\n') || command.includes('```'))
|
|
14
|
+
return undefined;
|
|
15
|
+
return [
|
|
16
|
+
LEAD,
|
|
17
|
+
'(action "command"), unchanged: no edits, no extra flags, no other commands. Then show me its',
|
|
18
|
+
'output and exit status, and do nothing else unless I ask.',
|
|
19
|
+
'',
|
|
20
|
+
'```sh',
|
|
21
|
+
command,
|
|
22
|
+
'```',
|
|
23
|
+
].join('\n');
|
|
24
|
+
}
|
|
25
|
+
/** The command a turn's prompt binds it to, when the prompt is exactly a `!command` request. */
|
|
26
|
+
export function boundShellCommand(prompt) {
|
|
27
|
+
if (!prompt.startsWith(LEAD + '\n'))
|
|
28
|
+
return undefined;
|
|
29
|
+
const lines = prompt.split('\n');
|
|
30
|
+
if (lines.length !== 7 || lines[4] !== '```sh' || lines[6] !== '```')
|
|
31
|
+
return undefined;
|
|
32
|
+
const command = lines[5];
|
|
33
|
+
return shellPassthrough('!' + command) === prompt ? command : undefined;
|
|
34
|
+
}
|
|
35
|
+
/** What the person typed, for display: `! COMMAND` for a `!command` prompt, else the text. */
|
|
36
|
+
export function typedText(prompt) {
|
|
37
|
+
const command = boundShellCommand(prompt);
|
|
38
|
+
return command === undefined ? prompt : '! ' + command;
|
|
39
|
+
}
|