@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
package/src/tui-client.ts
CHANGED
|
@@ -45,7 +45,15 @@ export interface UsageView {
|
|
|
45
45
|
known_output: number;
|
|
46
46
|
known_cost: number;
|
|
47
47
|
estimated_cost: number;
|
|
48
|
+
/** Prompt tokens read from or written to the provider's cache; most of a turn's input. */
|
|
49
|
+
known_cache_read?: number;
|
|
50
|
+
known_cache_write?: number;
|
|
48
51
|
}
|
|
52
|
+
/** All input tokens, cached or not. `known_input` alone is only the uncached part. */
|
|
53
|
+
export const usageInput = (u: UsageView) =>
|
|
54
|
+
u.known_input + (u.known_cache_read ?? 0) + (u.known_cache_write ?? 0);
|
|
55
|
+
/** Known and estimated costs come from different observations, so the spend is their sum. */
|
|
56
|
+
export const usageCost = (u: UsageView) => u.known_cost + u.estimated_cost;
|
|
49
57
|
export interface ToolActivity {
|
|
50
58
|
seq: number;
|
|
51
59
|
kind: string;
|
|
@@ -57,6 +65,8 @@ export interface ToolActivity {
|
|
|
57
65
|
input?: string;
|
|
58
66
|
input_truncated?: boolean;
|
|
59
67
|
failed?: boolean;
|
|
68
|
+
output?: string;
|
|
69
|
+
output_truncated?: boolean;
|
|
60
70
|
};
|
|
61
71
|
}
|
|
62
72
|
export interface LivePreview {
|
|
@@ -66,6 +76,15 @@ export interface LivePreview {
|
|
|
66
76
|
}
|
|
67
77
|
export type OperatorConnection = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
|
68
78
|
/** An operator client only: the service owns workers, authority and durable outcomes. */
|
|
79
|
+
/** "AGENTS.md" or "AGENTS.md (2 files)": which instruction files guide this session. */
|
|
80
|
+
export function instructionsLabel(
|
|
81
|
+
summary: import('./project-instructions.js').InstructionsSummary,
|
|
82
|
+
): string {
|
|
83
|
+
const names = [...new Set(summary.files.map((f) => f.path.split('/').at(-1)!))].join(' + ');
|
|
84
|
+
return summary.files.length > 1 ? `${names} (${summary.files.length} files)` : names;
|
|
85
|
+
}
|
|
86
|
+
/** Longest a stale control lease is waited out (the service's lease TTL is 15 s). */
|
|
87
|
+
const CONTROL_LAPSE_MS = 16000;
|
|
69
88
|
export class OperatorClient extends EventEmitter {
|
|
70
89
|
readonly socket: string;
|
|
71
90
|
readonly clientId = randomUUID();
|
|
@@ -142,7 +161,13 @@ export class OperatorClient extends EventEmitter {
|
|
|
142
161
|
policy: import('./autonomy.js').PermissionPolicy;
|
|
143
162
|
configurable: boolean;
|
|
144
163
|
coding: boolean;
|
|
145
|
-
command_auto_approval:
|
|
164
|
+
command_auto_approval: boolean;
|
|
165
|
+
/** Phase 2b: whether this host lets a session choose full access. */
|
|
166
|
+
full_access_allowed?: boolean;
|
|
167
|
+
/** Whether this desk session may choose YOLO. */
|
|
168
|
+
yolo_allowed?: boolean;
|
|
169
|
+
/** Where the next command runs, e.g. "in a Seatbelt sandbox (workspace-write)". */
|
|
170
|
+
command_sandbox?: string;
|
|
146
171
|
}> {
|
|
147
172
|
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
148
173
|
return call(this.socket, `/v1/sessions/${this.snapshot.id}/permissions`);
|
|
@@ -150,7 +175,11 @@ export class OperatorClient extends EventEmitter {
|
|
|
150
175
|
async setPermissions(
|
|
151
176
|
session: string,
|
|
152
177
|
expected: number,
|
|
153
|
-
policy: {
|
|
178
|
+
policy: {
|
|
179
|
+
mode: import('./autonomy.js').PermissionMode;
|
|
180
|
+
paths: string[];
|
|
181
|
+
access?: 'full' | 'yolo';
|
|
182
|
+
},
|
|
154
183
|
): Promise<void> {
|
|
155
184
|
if (this.snapshot?.id !== session)
|
|
156
185
|
throw new Error('Session changed; review permissions again.');
|
|
@@ -165,6 +194,64 @@ export class OperatorClient extends EventEmitter {
|
|
|
165
194
|
);
|
|
166
195
|
await this.refresh();
|
|
167
196
|
}
|
|
197
|
+
/** Desk-session approval rules (phase 2a). */
|
|
198
|
+
async approvalRules(): Promise<{
|
|
199
|
+
applies: boolean;
|
|
200
|
+
rules: import('./approval-rules.js').StoredRule[];
|
|
201
|
+
problems: string[];
|
|
202
|
+
}> {
|
|
203
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
204
|
+
return call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`);
|
|
205
|
+
}
|
|
206
|
+
/** Turn a reviewed request into a rule: its exact command words or its exact edited paths. */
|
|
207
|
+
async ruleFromOperation(operation: string, scope: 'session' | 'repo'): Promise<void> {
|
|
208
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
209
|
+
await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`, {
|
|
210
|
+
...this.actor(),
|
|
211
|
+
request_id: randomUUID(),
|
|
212
|
+
scope,
|
|
213
|
+
operation_id: operation,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async addRule(
|
|
217
|
+
scope: 'session' | 'repo',
|
|
218
|
+
rule: import('./approval-rules.js').ApprovalRule,
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
221
|
+
await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules`, {
|
|
222
|
+
...this.actor(),
|
|
223
|
+
request_id: randomUUID(),
|
|
224
|
+
scope,
|
|
225
|
+
rule,
|
|
226
|
+
});
|
|
227
|
+
this.report('Rule added.');
|
|
228
|
+
}
|
|
229
|
+
async removeRule(id: string): Promise<void> {
|
|
230
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
231
|
+
await call(this.socket, `/v1/sessions/${this.snapshot.id}/approval-rules/${id}/remove`, {
|
|
232
|
+
...this.actor(),
|
|
233
|
+
});
|
|
234
|
+
this.report('Rule removed.');
|
|
235
|
+
}
|
|
236
|
+
/** Your hooks and this workspace's, with their trust state (Phase 4b). */
|
|
237
|
+
async hooks(): Promise<{ sources: import('./hooks.js').HookSource[] }> {
|
|
238
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
239
|
+
return call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks`);
|
|
240
|
+
}
|
|
241
|
+
/** Trust this workspace's hooks file as reviewed; `digest` binds the content you saw. */
|
|
242
|
+
async trustHooks(digest: string): Promise<void> {
|
|
243
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
244
|
+
await call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks/trust`, {
|
|
245
|
+
...this.actor(),
|
|
246
|
+
digest,
|
|
247
|
+
});
|
|
248
|
+
this.report('Workspace hooks trusted. They run from the next tool call or prompt.');
|
|
249
|
+
}
|
|
250
|
+
async revokeHooks(): Promise<void> {
|
|
251
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
252
|
+
await call(this.socket, `/v1/sessions/${this.snapshot.id}/hooks/revoke`, { ...this.actor() });
|
|
253
|
+
this.report('Workspace hooks no longer trusted; they will not run.');
|
|
254
|
+
}
|
|
168
255
|
async steer(text: string): Promise<void> {
|
|
169
256
|
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
170
257
|
const result = await call<{ state: string }>(
|
|
@@ -208,6 +295,99 @@ export class OperatorClient extends EventEmitter {
|
|
|
208
295
|
await this.refresh();
|
|
209
296
|
this.report('Reverted the reviewed local edit. Conversation history is preserved.');
|
|
210
297
|
}
|
|
298
|
+
async checkpoints(): Promise<import('./turn-rewind.js').TurnCheckpoint[]> {
|
|
299
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
300
|
+
return (
|
|
301
|
+
await call<{ checkpoints: import('./turn-rewind.js').TurnCheckpoint[] }>(
|
|
302
|
+
this.socket,
|
|
303
|
+
`/v1/sessions/${this.snapshot.id}/checkpoints`,
|
|
304
|
+
)
|
|
305
|
+
).checkpoints;
|
|
306
|
+
}
|
|
307
|
+
async rewindReview(
|
|
308
|
+
attempt: string,
|
|
309
|
+
mode: import('./turn-rewind.js').RewindMode,
|
|
310
|
+
): Promise<import('./turn-rewind.js').RewindReview> {
|
|
311
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
312
|
+
return call(
|
|
313
|
+
this.socket,
|
|
314
|
+
`/v1/sessions/${this.snapshot.id}/rewind?attempt=${encodeURIComponent(attempt)}&mode=${mode}`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
/** Returns the rewound turn's instruction so it can be edited and sent again. */
|
|
318
|
+
async rewind(review: import('./turn-rewind.js').RewindReview): Promise<string> {
|
|
319
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
320
|
+
const id = this.snapshot.id;
|
|
321
|
+
const result = await call<{ state: string; session_id: string; text: string }>(
|
|
322
|
+
this.socket,
|
|
323
|
+
`/v1/sessions/${id}/rewind`,
|
|
324
|
+
{
|
|
325
|
+
...this.actor(),
|
|
326
|
+
request_id: randomUUID(),
|
|
327
|
+
attempt_id: review.attempt_id,
|
|
328
|
+
mode: review.mode,
|
|
329
|
+
review_digest: review.digest,
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
if (result.state !== 'completed')
|
|
333
|
+
throw new Error('Rewind outcome is uncertain; inspect files before continuing.');
|
|
334
|
+
if (result.session_id !== id) await this.open(result.session_id);
|
|
335
|
+
else await this.refresh();
|
|
336
|
+
this.report(
|
|
337
|
+
(review.mode === 'conversation'
|
|
338
|
+
? 'Conversation'
|
|
339
|
+
: review.mode === 'files'
|
|
340
|
+
? 'Files'
|
|
341
|
+
: 'Files and conversation') +
|
|
342
|
+
` rewound to before turn ${review.turn}.` +
|
|
343
|
+
(result.session_id !== id ? ' The earlier conversation stays as its own session.' : ''),
|
|
344
|
+
);
|
|
345
|
+
return result.text;
|
|
346
|
+
}
|
|
347
|
+
async changeReview(): Promise<import('./turn-rewind.js').ChangeReview | null> {
|
|
348
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
349
|
+
return (
|
|
350
|
+
await call<{ review: import('./turn-rewind.js').ChangeReview | null }>(
|
|
351
|
+
this.socket,
|
|
352
|
+
`/v1/sessions/${this.snapshot.id}/review`,
|
|
353
|
+
)
|
|
354
|
+
).review;
|
|
355
|
+
}
|
|
356
|
+
async changeHunks(
|
|
357
|
+
review: import('./turn-rewind.js').ChangeReview,
|
|
358
|
+
path: string,
|
|
359
|
+
): Promise<import('./turn-checkpoints.js').CheckpointHunk[]> {
|
|
360
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
361
|
+
const q = new URLSearchParams({ path, base: review.base, current: review.current });
|
|
362
|
+
return (
|
|
363
|
+
await call<{ hunks: import('./turn-checkpoints.js').CheckpointHunk[] }>(
|
|
364
|
+
this.socket,
|
|
365
|
+
`/v1/sessions/${this.snapshot.id}/review/hunks?${q}`,
|
|
366
|
+
)
|
|
367
|
+
).hunks;
|
|
368
|
+
}
|
|
369
|
+
async changeAct(
|
|
370
|
+
review: import('./turn-rewind.js').ChangeReview,
|
|
371
|
+
action: 'accept' | 'revert',
|
|
372
|
+
path: string,
|
|
373
|
+
hunk?: import('./turn-checkpoints.js').CheckpointHunk,
|
|
374
|
+
): Promise<import('./turn-rewind.js').ChangeReview | null> {
|
|
375
|
+
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
376
|
+
return (
|
|
377
|
+
await call<{ review: import('./turn-rewind.js').ChangeReview | null }>(
|
|
378
|
+
this.socket,
|
|
379
|
+
`/v1/sessions/${this.snapshot.id}/review`,
|
|
380
|
+
{
|
|
381
|
+
...this.actor(),
|
|
382
|
+
action,
|
|
383
|
+
path,
|
|
384
|
+
base: review.base,
|
|
385
|
+
current: review.current,
|
|
386
|
+
...(hunk ? { hunk } : {}),
|
|
387
|
+
},
|
|
388
|
+
)
|
|
389
|
+
).review;
|
|
390
|
+
}
|
|
211
391
|
async fork(): Promise<void> {
|
|
212
392
|
if (!this.snapshot) throw new Error('Choose a session first.');
|
|
213
393
|
const session = await call<Session>(this.socket, `/v1/sessions/${this.snapshot.id}/fork`, {
|
|
@@ -291,15 +471,15 @@ export class OperatorClient extends EventEmitter {
|
|
|
291
471
|
this.snapshot = await call<SessionSnapshot>(this.socket, `/v1/sessions/${id}/snapshot`);
|
|
292
472
|
if (!options.observe) {
|
|
293
473
|
try {
|
|
294
|
-
this.lease = await
|
|
474
|
+
this.lease = await this.acquireOrWait(id, options.takeover);
|
|
295
475
|
} catch (error) {
|
|
296
476
|
if (
|
|
297
477
|
!(error instanceof ServiceError) ||
|
|
298
|
-
!['control_conflict', 'control_required'].includes(error.code)
|
|
478
|
+
!['control_conflict', 'control_required', 'control_busy'].includes(error.code)
|
|
299
479
|
)
|
|
300
480
|
throw error;
|
|
301
481
|
this.notice =
|
|
302
|
-
'Another
|
|
482
|
+
'Another terminal is using this session and still renews its control. You are observing; press ctrl+k and choose Take control to take it over.';
|
|
303
483
|
}
|
|
304
484
|
}
|
|
305
485
|
if (this.lease) await call(this.socket, `/v1/sessions/${id}/resume`, this.actor());
|
|
@@ -313,7 +493,26 @@ export class OperatorClient extends EventEmitter {
|
|
|
313
493
|
this.watcher = this.watch(id, generation);
|
|
314
494
|
this.changed();
|
|
315
495
|
}
|
|
316
|
-
|
|
496
|
+
/** Acquire control. A lease left by a terminal that was killed lapses within its short TTL, so
|
|
497
|
+
* wait that out once instead of opening as an observer; a live terminal renews and keeps it. */
|
|
498
|
+
private async acquireOrWait(id: string, takeover?: boolean): Promise<ControlLease> {
|
|
499
|
+
try {
|
|
500
|
+
return await acquireControl(this.socket, id, this.clientId, takeover);
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (takeover || !(error instanceof ServiceError) || error.code !== 'control_busy')
|
|
503
|
+
throw error;
|
|
504
|
+
const snapshot = await call<SessionSnapshot>(this.socket, `/v1/sessions/${id}/snapshot`);
|
|
505
|
+
const wait = snapshot.control.expires_at - Date.now();
|
|
506
|
+
if (wait <= 0 || wait > CONTROL_LAPSE_MS) throw error;
|
|
507
|
+
this.notice = `Waiting ${Math.ceil(wait / 1000)} s for the previous terminal's control to lapse…`;
|
|
508
|
+
this.changed();
|
|
509
|
+
await new Promise((done) => setTimeout(done, wait + 250));
|
|
510
|
+
const lease = await acquireControl(this.socket, id, this.clientId);
|
|
511
|
+
this.notice = 'You have control: the previous terminal had stopped.';
|
|
512
|
+
return lease;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
actor(): { client_id: string; token: string } {
|
|
317
516
|
if (!this.lease) throw new Error('Take control before sending an instruction.');
|
|
318
517
|
return { client_id: this.clientId, token: this.lease.token };
|
|
319
518
|
}
|
|
@@ -358,10 +557,10 @@ export class OperatorClient extends EventEmitter {
|
|
|
358
557
|
await job;
|
|
359
558
|
this.changed();
|
|
360
559
|
}
|
|
361
|
-
async submit(text: string, continues?: string): Promise<string> {
|
|
560
|
+
async submit(text: string, continues?: string, confirmSensitive = false): Promise<string> {
|
|
362
561
|
this.actor();
|
|
363
562
|
if (!this.snapshot || !this.outbox) throw new Error('Open a session first.');
|
|
364
|
-
const record = this.outbox.enqueue(text, this.snapshot.revision, continues);
|
|
563
|
+
const record = this.outbox.enqueue(text, this.snapshot.revision, continues, confirmSensitive);
|
|
365
564
|
this.notice = 'Instruction saved locally. Delivering…';
|
|
366
565
|
this.changed();
|
|
367
566
|
try {
|
|
@@ -374,6 +573,8 @@ export class OperatorClient extends EventEmitter {
|
|
|
374
573
|
'Delivery pending. The saved instruction will be retried: ' + (error as Error).message;
|
|
375
574
|
}
|
|
376
575
|
await this.refresh().catch(() => {});
|
|
576
|
+
// A failed worker cannot take the instruction: say so instead of "accepted".
|
|
577
|
+
this.notice = sessionFailure(this.snapshot) ?? this.notice;
|
|
377
578
|
this.changed();
|
|
378
579
|
return record.request_id;
|
|
379
580
|
}
|
|
@@ -469,6 +670,19 @@ export class OperatorClient extends EventEmitter {
|
|
|
469
670
|
this.notice = 'Reviewed context admitted. Tool permissions are unchanged.';
|
|
470
671
|
await this.refresh();
|
|
471
672
|
}
|
|
673
|
+
/** Switch this session's model or thinking level between turns; the conversation carries on. */
|
|
674
|
+
async switchRoute(route: import('./provider-config.js').ProviderRoute): Promise<void> {
|
|
675
|
+
if (!this.snapshot) throw new Error('Open a session first.');
|
|
676
|
+
await call(this.socket, `/v1/sessions/${this.snapshot.id}/route`, {
|
|
677
|
+
...this.actor(),
|
|
678
|
+
route: {
|
|
679
|
+
provider: route.provider === 'salaam-proof' ? 'offline' : route.provider,
|
|
680
|
+
...(route.provider === 'salaam-proof' ? {} : { model: route.model }),
|
|
681
|
+
...(route.reasoning === undefined ? {} : { reasoning: route.reasoning }),
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
await this.refresh();
|
|
685
|
+
}
|
|
472
686
|
async decide(approval: Approval, decision: 'approve' | 'deny'): Promise<void> {
|
|
473
687
|
if (!this.outbox) throw new Error('Open a session first.');
|
|
474
688
|
this.outbox.enqueueDecision(
|
|
@@ -572,6 +786,13 @@ export class OperatorClient extends EventEmitter {
|
|
|
572
786
|
optional<ToolActivity[]>(`/v1/sessions/${id}/activity`),
|
|
573
787
|
]);
|
|
574
788
|
if (generation !== this.generation) return;
|
|
789
|
+
const before =
|
|
790
|
+
this.snapshot?.id === snapshot.id ? this.snapshot.project_instructions : null;
|
|
791
|
+
const after = snapshot.project_instructions;
|
|
792
|
+
if (before && after && after.revision > before.revision)
|
|
793
|
+
this.notice = after.files.length
|
|
794
|
+
? 'Project instructions changed · reloaded ' + instructionsLabel(after)
|
|
795
|
+
: 'Project instructions removed';
|
|
575
796
|
this.snapshot = snapshot;
|
|
576
797
|
this.health = health;
|
|
577
798
|
this.commands = commands ?? [];
|
|
@@ -616,6 +837,7 @@ export class OperatorClient extends EventEmitter {
|
|
|
616
837
|
(event: DurableEvent) => {
|
|
617
838
|
if (event.seq <= cursor || generation !== this.generation) return;
|
|
618
839
|
cursor = event.seq;
|
|
840
|
+
this.emit('durable', event);
|
|
619
841
|
if (
|
|
620
842
|
[
|
|
621
843
|
'attempt.completed',
|
|
@@ -692,3 +914,12 @@ export class OperatorClient extends EventEmitter {
|
|
|
692
914
|
await this.leave();
|
|
693
915
|
}
|
|
694
916
|
}
|
|
917
|
+
|
|
918
|
+
/** What went wrong when a session's worker has failed, for the status line and the Changes tab. */
|
|
919
|
+
export function sessionFailure(
|
|
920
|
+
s: { state: string; error: string | null } | undefined,
|
|
921
|
+
): string | undefined {
|
|
922
|
+
if (!s) return undefined;
|
|
923
|
+
if (s.state !== 'unhealthy' && !(s.state === 'exited' && s.error)) return undefined;
|
|
924
|
+
return `Session stopped: ${s.error ?? 'the worker is not healthy'}. Fix it, then /new or ctrl+n for a fresh session.`;
|
|
925
|
+
}
|
package/src/tui-commands.ts
CHANGED
|
@@ -17,15 +17,11 @@ export const SLASH_COMMANDS: readonly SlashDefinition[] = [
|
|
|
17
17
|
command('help', 'Show commands and their execution type', ['commands'], '[command]'),
|
|
18
18
|
command('exit', 'Detach; preserve the session and running work', ['quit', 'q']),
|
|
19
19
|
command('keys', 'Show keyboard shortcuts', ['hotkeys']),
|
|
20
|
-
command('model', '
|
|
21
|
-
command('provider', '
|
|
22
|
-
command('thinking', '
|
|
23
|
-
command(
|
|
24
|
-
|
|
25
|
-
'New local coding session with reviewed edits and unsandboxed commands',
|
|
26
|
-
[],
|
|
27
|
-
'[workspace]',
|
|
28
|
-
),
|
|
20
|
+
command('model', 'Switch this session’s provider and model', ['models'], '[provider/model]'),
|
|
21
|
+
command('provider', 'Switch this session’s provider', [], '[provider]'),
|
|
22
|
+
command('thinking', 'Switch this session’s reasoning effort', [], '[level]'),
|
|
23
|
+
command('coding', 'New local coding session with reviewed edits and commands', [], '[workspace]'),
|
|
24
|
+
command('init', 'Draft an AGENTS.md for this repository; the edit asks for approval'),
|
|
29
25
|
command('new', 'Create a session in a workspace', [], '[workspace]'),
|
|
30
26
|
command('sessions', 'Switch or resume a session', ['resume'], '[session-id]'),
|
|
31
27
|
command('name', 'Set a durable session label', ['rename'], '<name>'),
|
|
@@ -39,9 +35,30 @@ export const SLASH_COMMANDS: readonly SlashDefinition[] = [
|
|
|
39
35
|
[],
|
|
40
36
|
'<text>',
|
|
41
37
|
),
|
|
42
|
-
command('diff', 'Review workspace changes'),
|
|
38
|
+
command('diff', 'Review workspace changes file by file'),
|
|
39
|
+
command(
|
|
40
|
+
'commit',
|
|
41
|
+
'Draft a commit of the current work; staging and the commit each ask (uses provider credits)',
|
|
42
|
+
[],
|
|
43
|
+
undefined,
|
|
44
|
+
'model',
|
|
45
|
+
),
|
|
46
|
+
command('branch', 'Switch to a branch, creating it if needed', [], '<name>', 'model'),
|
|
47
|
+
command(
|
|
48
|
+
'pr',
|
|
49
|
+
'Push this branch and open a pull request; both always ask, except in YOLO',
|
|
50
|
+
[],
|
|
51
|
+
'[base]',
|
|
52
|
+
'model',
|
|
53
|
+
),
|
|
43
54
|
command('tools', 'Inspect available capabilities and recorded tool activity'),
|
|
44
55
|
command('permissions', 'Configure local permissions independently of task progression'),
|
|
56
|
+
command('mode', 'Cycle review → accept edits → auto (shift+tab)'),
|
|
57
|
+
command(
|
|
58
|
+
'full-access',
|
|
59
|
+
'Turn full access on or off: commands run without the OS sandbox; dangerous ones still ask',
|
|
60
|
+
),
|
|
61
|
+
command('yolo', 'Turn YOLO on (type yolo to confirm) or off: no sandbox and no approvals'),
|
|
45
62
|
command('approvals', 'Review exact pending approval requests'),
|
|
46
63
|
command('context', 'Inspect session context, facts and usage'),
|
|
47
64
|
command('usage', 'Show recorded usage and unknown observations'),
|
|
@@ -50,6 +67,7 @@ export const SLASH_COMMANDS: readonly SlashDefinition[] = [
|
|
|
50
67
|
command('export', 'Export full durable conversation as Markdown; never overwrite', [], '[path]'),
|
|
51
68
|
command('settings', 'Choose terminal appearance and model settings'),
|
|
52
69
|
command('fork', 'Fork an idle local conversation; keep the same workspace'),
|
|
70
|
+
command('rewind', 'Rewind files, conversation or both to before an earlier turn', [], '[turn]'),
|
|
53
71
|
command(
|
|
54
72
|
'undo',
|
|
55
73
|
'Review and revert the last completed local edit; retain conversation history',
|
|
@@ -80,6 +98,19 @@ export const SLASH_COMMANDS: readonly SlashDefinition[] = [
|
|
|
80
98
|
command('agents', 'Inspect native/external worker profiles and child work'),
|
|
81
99
|
command('progression', 'Choose manual or bounded task progression; permissions stay separate'),
|
|
82
100
|
command('tasks', 'Open durable task actions'),
|
|
101
|
+
command(
|
|
102
|
+
'classifier',
|
|
103
|
+
'Pick the small-model command reviewer for this session, or turn it off',
|
|
104
|
+
[],
|
|
105
|
+
'[name|on|off]',
|
|
106
|
+
),
|
|
107
|
+
command(
|
|
108
|
+
'hooks',
|
|
109
|
+
'Show your hooks and this workspace’s; trust or revoke the workspace file',
|
|
110
|
+
[],
|
|
111
|
+
'[trust|revoke]',
|
|
112
|
+
),
|
|
113
|
+
command('jobs', 'Show background commands and their output, or stop one', ['bg'], '[stop <id>]'),
|
|
83
114
|
];
|
|
84
115
|
export const SLASH_COMPLETIONS = SLASH_COMMANDS.flatMap((c) =>
|
|
85
116
|
[c.name, ...c.aliases].map((name) => ({ name, description: c.description })),
|
|
@@ -125,3 +156,11 @@ export function slashHelp(name?: string): string {
|
|
|
125
156
|
)
|
|
126
157
|
.join('\n');
|
|
127
158
|
}
|
|
159
|
+
|
|
160
|
+
/** What /init asks the agent to do. The write goes through the session's normal edit approval. */
|
|
161
|
+
export const INIT_INSTRUCTION = [
|
|
162
|
+
'Draft an AGENTS.md for this repository.',
|
|
163
|
+
'First read the tree and the files that define how it is built and tested (package manifests, build and CI configuration, README, existing contributor docs, any CLAUDE.md).',
|
|
164
|
+
'Then write AGENTS.md at the repository root: a short overview, the exact commands to build, test, lint and format, the code layout, and conventions a contributor must follow.',
|
|
165
|
+
'Keep it under 150 lines, state only what the files show, and if AGENTS.md already exists, propose improvements to it instead of replacing it.',
|
|
166
|
+
].join(' ');
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Composer behaviour for the trial-polish batch (operator approval 2026-10-31): Ctrl+C in steps,
|
|
7
|
+
* reverse history search, persisted history that never keeps secret-looking lines, and `!command`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** What Ctrl+C does now: stop a running turn, else clear typed text, else arm, else leave. */
|
|
11
|
+
export type CtrlCAction = 'interrupt' | 'clear' | 'arm' | 'exit';
|
|
12
|
+
export const CTRL_C_WINDOW_MS = 2000;
|
|
13
|
+
|
|
14
|
+
export function ctrlCAction(input: {
|
|
15
|
+
running: boolean;
|
|
16
|
+
text: string;
|
|
17
|
+
armedAt: number | undefined;
|
|
18
|
+
now: number;
|
|
19
|
+
}): CtrlCAction {
|
|
20
|
+
if (input.running) return 'interrupt';
|
|
21
|
+
if (input.text !== '') return 'clear';
|
|
22
|
+
if (input.armedAt !== undefined && input.now - input.armedAt <= CTRL_C_WINDOW_MS) return 'exit';
|
|
23
|
+
return 'arm';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SECRET_PATTERNS: RegExp[] = [
|
|
27
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
28
|
+
/\b(sk|rk|pk)-[A-Za-z0-9_-]{16,}/,
|
|
29
|
+
/\bsk-ant-[A-Za-z0-9_-]{8,}/,
|
|
30
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/,
|
|
31
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}/,
|
|
32
|
+
/\bxox[abprs]-[A-Za-z0-9-]{10,}/,
|
|
33
|
+
/\bAKIA[0-9A-Z]{16}\b/,
|
|
34
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\./,
|
|
35
|
+
/\b(password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*\S{6,}/i,
|
|
36
|
+
/\bbearer\s+[A-Za-z0-9._~+/-]{16,}/i,
|
|
37
|
+
/[A-Za-z0-9+_-]{48,}={0,2}/,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** True when the text looks like it carries a credential; such text is never written to history. */
|
|
41
|
+
export function looksSecret(text: string): boolean {
|
|
42
|
+
return SECRET_PATTERNS.some((p) => p.test(text));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const HISTORY_LIMIT = 500;
|
|
46
|
+
export const HISTORY_ENTRY_LIMIT = 8192;
|
|
47
|
+
|
|
48
|
+
/** Where this user's composer history lives: $XDG_STATE_HOME/salaam/history.json, or nowhere when
|
|
49
|
+
* SALAAM_HISTORY=off (history then lasts only for this terminal). */
|
|
50
|
+
export function historyPath(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
51
|
+
if (env.SALAAM_HISTORY === 'off') return undefined;
|
|
52
|
+
const base = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
|
|
53
|
+
return join(base, 'salaam', 'history.json');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Per-user composer history, newest last, bounded, and without secret-looking entries. */
|
|
57
|
+
export class ComposerHistory {
|
|
58
|
+
entries: string[] = [];
|
|
59
|
+
constructor(private readonly path: string | undefined) {
|
|
60
|
+
try {
|
|
61
|
+
if (path && existsSync(path)) {
|
|
62
|
+
const raw: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
63
|
+
if (Array.isArray(raw))
|
|
64
|
+
this.entries = raw
|
|
65
|
+
.filter((e): e is string => typeof e === 'string')
|
|
66
|
+
.filter((e) => keepable(e))
|
|
67
|
+
.slice(-HISTORY_LIMIT);
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
this.entries = [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Adds a sent message. Returns false when it was not kept (blank, too long, or secret-looking). */
|
|
74
|
+
add(text: string): boolean {
|
|
75
|
+
if (!keepable(text)) return false;
|
|
76
|
+
this.entries = this.entries.filter((e) => e !== text);
|
|
77
|
+
this.entries.push(text);
|
|
78
|
+
if (this.entries.length > HISTORY_LIMIT) this.entries = this.entries.slice(-HISTORY_LIMIT);
|
|
79
|
+
this.save();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
private save(): void {
|
|
83
|
+
if (!this.path) return;
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
86
|
+
const tmp = this.path + '.tmp';
|
|
87
|
+
writeFileSync(tmp, JSON.stringify(this.entries), { mode: 0o600 });
|
|
88
|
+
chmodSync(tmp, 0o600);
|
|
89
|
+
renameSync(tmp, this.path);
|
|
90
|
+
} catch {
|
|
91
|
+
// History is a convenience; failing to save never blocks sending.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function keepable(text: string): boolean {
|
|
97
|
+
return text.trim() !== '' && text.length <= HISTORY_ENTRY_LIMIT && !looksSecret(text);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Reverse-incremental search: the newest entry older than `before` (an index into `entries`,
|
|
102
|
+
* newest last) that contains the query, case-insensitively. Returns its index or -1.
|
|
103
|
+
*/
|
|
104
|
+
export function searchHistory(entries: string[], query: string, before: number): number {
|
|
105
|
+
const q = query.toLowerCase();
|
|
106
|
+
for (let i = Math.min(before, entries.length) - 1; i >= 0; i--)
|
|
107
|
+
if (entries[i]!.toLowerCase().includes(q)) return i;
|
|
108
|
+
return -1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The status-line text while searching, matching readline's wording. */
|
|
112
|
+
export function searchPrompt(query: string, match: string | undefined): string {
|
|
113
|
+
const shown = match === undefined ? '' : match.replace(/\s+/g, ' ').slice(0, 120);
|
|
114
|
+
return match === undefined && query !== ''
|
|
115
|
+
? `(failing reverse-i-search)\`${query}': · esc cancels`
|
|
116
|
+
: `(reverse-i-search)\`${query}': ${shown} · enter keeps · ctrl+r older · esc cancels`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* `!command` from the composer. The TUI never runs it itself: it becomes an instruction for the
|
|
121
|
+
* model to run exactly this command through salaam_local, so it takes the same approval path,
|
|
122
|
+
* session mode, sandbox and danger check as any model command. Returns undefined for other text.
|
|
123
|
+
*/
|
|
124
|
+
export { shellPassthrough } from './shell-passthrough.js';
|