@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/task-work.ts
CHANGED
|
@@ -98,6 +98,80 @@ export class TaskWork {
|
|
|
98
98
|
.prepare('UPDATE task_runtime SET remaining_steps=remaining_steps-1 WHERE task_id=?')
|
|
99
99
|
.run(task.id);
|
|
100
100
|
}
|
|
101
|
+
/** Item 8: re-run the last completed step with the critic's findings added to its instruction,
|
|
102
|
+
* under its own re-prepared progression grant and a request of its own. */
|
|
103
|
+
reopenForCritic(
|
|
104
|
+
taskId: string,
|
|
105
|
+
stepId: string,
|
|
106
|
+
round: number,
|
|
107
|
+
epoch: number,
|
|
108
|
+
findings: string,
|
|
109
|
+
): unknown {
|
|
110
|
+
const task = this.get(taskId);
|
|
111
|
+
const step = task.steps.find((s) => s.id === stepId);
|
|
112
|
+
const grant = this.store.db
|
|
113
|
+
.prepare(
|
|
114
|
+
"SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'",
|
|
115
|
+
)
|
|
116
|
+
.get(task.id, stepId, task.plan_revision, epoch) as { id: string } | undefined;
|
|
117
|
+
if (!grant || !step || step.state !== 'completed' || task.steps.at(-1)?.id !== stepId)
|
|
118
|
+
throw new ServiceError('task_authority', 'Only the completed last step can be reopened');
|
|
119
|
+
this.store.transaction(() => {
|
|
120
|
+
this.store.db.prepare("UPDATE task_steps SET state='pending' WHERE id=?").run(stepId);
|
|
121
|
+
this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
|
|
122
|
+
this.store.event(task.session_id, 'work_task.step_reopened', {
|
|
123
|
+
task_id: task.id,
|
|
124
|
+
step_id: stepId,
|
|
125
|
+
grant_id: grant.id,
|
|
126
|
+
round,
|
|
127
|
+
reason: 'Critic requested changes',
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
return this.run(
|
|
131
|
+
task.id,
|
|
132
|
+
stepId,
|
|
133
|
+
'task-grant:' + grant.id + ':critic-reopen:' + round,
|
|
134
|
+
this.get(task.id).revision,
|
|
135
|
+
step.instruction +
|
|
136
|
+
'\n\nBefore this work is accepted, a reviewer asked for these changes:\n' +
|
|
137
|
+
findings,
|
|
138
|
+
null,
|
|
139
|
+
null,
|
|
140
|
+
grant.id,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
/** Re-run a step the provider rate-limited, as a fresh attempt under its own progression grant
|
|
144
|
+
* (re-prepared, with a request of its own, so it is never a replay of the refused attempt). */
|
|
145
|
+
retryStep(taskId: string, stepId: string, n: number, epoch: number): unknown {
|
|
146
|
+
const task = this.get(taskId);
|
|
147
|
+
const grant = this.store.db
|
|
148
|
+
.prepare(
|
|
149
|
+
"SELECT id FROM task_grants WHERE task_id=? AND step_id=? AND plan_revision=? AND epoch=? AND state='accepted'",
|
|
150
|
+
)
|
|
151
|
+
.get(task.id, stepId, task.plan_revision, epoch) as { id: string } | undefined;
|
|
152
|
+
if (!grant || task.steps.find((s) => s.id === stepId)?.state !== 'failed')
|
|
153
|
+
throw new ServiceError('task_authority', 'No settled grant for this rate-limited step');
|
|
154
|
+
this.store.transaction(() => {
|
|
155
|
+
this.store.db.prepare("UPDATE task_grants SET state='prepared' WHERE id=?").run(grant.id);
|
|
156
|
+
this.store.event(task.session_id, 'work_task.step_retried', {
|
|
157
|
+
task_id: task.id,
|
|
158
|
+
step_id: stepId,
|
|
159
|
+
grant_id: grant.id,
|
|
160
|
+
retry: n,
|
|
161
|
+
reason: 'Provider rate limit',
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
return this.run(
|
|
165
|
+
task.id,
|
|
166
|
+
stepId,
|
|
167
|
+
'task-grant:' + grant.id + ':rate-limit-retry:' + n,
|
|
168
|
+
task.revision,
|
|
169
|
+
null,
|
|
170
|
+
null,
|
|
171
|
+
null,
|
|
172
|
+
grant.id,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
101
175
|
advance(grantId: string): unknown {
|
|
102
176
|
const grant = this.store.db.prepare('SELECT * FROM task_grants WHERE id=?').get(grantId) as
|
|
103
177
|
{ task_id: string; step_id: string; expected_revision: number } | undefined;
|
|
@@ -693,6 +767,7 @@ export class TaskWork {
|
|
|
693
767
|
objective: task.objective,
|
|
694
768
|
constraints: task.constraints,
|
|
695
769
|
acceptance: task.acceptance,
|
|
770
|
+
...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
|
|
696
771
|
deliverables: task.deliverables,
|
|
697
772
|
limits: task.limits,
|
|
698
773
|
active_step_id: task.steps.find((s) => s.state === 'running')?.id ?? null,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Salaam tells the terminal what it is doing (trial polish C, 2026-10-31): the window title says
|
|
5
|
+
* working / needs you, and inside Herdr the pane reports its state through Herdr's documented
|
|
6
|
+
* `pane report-agent` command (herdrdev/herdr, docs/next/.../add-herdr-support.mdx). Herdr's
|
|
7
|
+
* docs define no OSC 7501 sequence, so none is sent. SALAAM_TERMINAL_STATUS=off turns both off.
|
|
8
|
+
* Neither carries prompt text, command text or anything secret: only the state and a short
|
|
9
|
+
* session name.
|
|
10
|
+
*/
|
|
11
|
+
export type TerminalState = 'idle' | 'working' | 'blocked';
|
|
12
|
+
|
|
13
|
+
export function terminalState(
|
|
14
|
+
snapshot: { attempts?: { state: string }[]; approvals?: { state: string }[] } | undefined,
|
|
15
|
+
): TerminalState {
|
|
16
|
+
if (!snapshot) return 'idle';
|
|
17
|
+
if ((snapshot.approvals ?? []).some((a) => a.state === 'pending')) return 'blocked';
|
|
18
|
+
if (
|
|
19
|
+
(snapshot.attempts ?? []).some((a) => ['accepted', 'dispatching', 'running'].includes(a.state))
|
|
20
|
+
)
|
|
21
|
+
return 'working';
|
|
22
|
+
return 'idle';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function titleFor(state: TerminalState, name: string | undefined): string {
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
const clean = name
|
|
28
|
+
?.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
29
|
+
.trim()
|
|
30
|
+
.slice(0, 32);
|
|
31
|
+
if (!clean) return 'Salaam';
|
|
32
|
+
const words = state === 'blocked' ? 'needs you' : state === 'working' ? 'working' : 'idle';
|
|
33
|
+
return `Salaam · ${clean} · ${words}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function statusEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
37
|
+
return env.SALAAM_TERMINAL_STATUS !== 'off';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Run = (bin: string, args: string[], done: () => void) => void;
|
|
41
|
+
const run: Run = (bin, args, done) => {
|
|
42
|
+
// Short timeout, failures ignored: Herdr must never slow Salaam down.
|
|
43
|
+
execFile(bin, args, { timeout: 2000, windowsHide: true }, () => done());
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Reports to Herdr only inside a Herdr pane; one report in flight, newest state wins. */
|
|
47
|
+
export class HerdrReporter {
|
|
48
|
+
readonly enabled: boolean;
|
|
49
|
+
private seq = 0;
|
|
50
|
+
private busy = false;
|
|
51
|
+
private queued?: string[];
|
|
52
|
+
private last?: string;
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly env: NodeJS.ProcessEnv = process.env,
|
|
55
|
+
private readonly exec: Run = run,
|
|
56
|
+
private readonly now: () => number = Date.now,
|
|
57
|
+
) {
|
|
58
|
+
this.enabled = !!(env.HERDR_ENV === '1' && env.HERDR_PANE_ID && env.HERDR_BIN_PATH);
|
|
59
|
+
}
|
|
60
|
+
private nextSeq(): string {
|
|
61
|
+
this.seq = Math.max(this.seq + 1, this.now());
|
|
62
|
+
return String(this.seq);
|
|
63
|
+
}
|
|
64
|
+
private head(verb: string): string[] {
|
|
65
|
+
return ['pane', verb, this.env.HERDR_PANE_ID!, '--source', 'salaam', '--agent', 'salaam'];
|
|
66
|
+
}
|
|
67
|
+
report(state: TerminalState, session: string | undefined): void {
|
|
68
|
+
if (!this.enabled) return;
|
|
69
|
+
const key = state + ' ' + (session ?? '');
|
|
70
|
+
if (key === this.last) return;
|
|
71
|
+
this.last = key;
|
|
72
|
+
this.queued = [
|
|
73
|
+
...this.head('report-agent'),
|
|
74
|
+
'--state',
|
|
75
|
+
state,
|
|
76
|
+
'--seq',
|
|
77
|
+
'',
|
|
78
|
+
...(state === 'blocked' ? ['--message', 'Waiting for your approval'] : []),
|
|
79
|
+
...(session ? ['--agent-session-id', session, '--', 'salaam', '--session', session] : []),
|
|
80
|
+
];
|
|
81
|
+
this.flush();
|
|
82
|
+
}
|
|
83
|
+
release(): void {
|
|
84
|
+
if (!this.enabled) return;
|
|
85
|
+
this.queued = undefined;
|
|
86
|
+
this.exec(
|
|
87
|
+
this.env.HERDR_BIN_PATH!,
|
|
88
|
+
[...this.head('release-agent'), '--seq', this.nextSeq()],
|
|
89
|
+
() => {},
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
private flush(): void {
|
|
93
|
+
if (this.busy || !this.queued) return;
|
|
94
|
+
const args = this.queued;
|
|
95
|
+
this.queued = undefined;
|
|
96
|
+
args[args.indexOf('--seq') + 1] = this.nextSeq();
|
|
97
|
+
this.busy = true;
|
|
98
|
+
this.exec(this.env.HERDR_BIN_PATH!, args, () => {
|
|
99
|
+
this.busy = false;
|
|
100
|
+
this.flush();
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { userInfo } from 'node:os';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { ServiceError } from './protocol.js';
|
|
7
|
+
import type { LocalEdit } from './local-edits.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The OS identity that agent-initiated local commands and file effects run as.
|
|
11
|
+
* `service-user` is the single-user default; `tools-user` switches through a
|
|
12
|
+
* runas-restricted `sudo -n -u <user>` rule so the daemon needs no capability.
|
|
13
|
+
*/
|
|
14
|
+
export interface ToolIdentity {
|
|
15
|
+
mode: 'service-user' | 'tools-user';
|
|
16
|
+
user: string;
|
|
17
|
+
uid: number;
|
|
18
|
+
/** argv prefix that runs a program as the tool identity; empty for service-user. */
|
|
19
|
+
prefix: string[];
|
|
20
|
+
warning: string | null;
|
|
21
|
+
}
|
|
22
|
+
export const sharedUserWarning = (user: string) =>
|
|
23
|
+
`Tool execution runs as ${user}, the same OS user that holds the provider credential. Set --tools-user to separate them.`;
|
|
24
|
+
|
|
25
|
+
export function serviceToolIdentity(): ToolIdentity {
|
|
26
|
+
const user = userInfo().username;
|
|
27
|
+
return {
|
|
28
|
+
mode: 'service-user',
|
|
29
|
+
user,
|
|
30
|
+
uid: process.getuid?.() ?? -1,
|
|
31
|
+
prefix: [],
|
|
32
|
+
warning: sharedUserWarning(user),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const run = (argv: string[], input?: string) =>
|
|
37
|
+
spawnSync(argv[0]!, argv.slice(1), {
|
|
38
|
+
env: { PATH: '/usr/local/bin:/usr/bin:/bin', LANG: 'C' },
|
|
39
|
+
input,
|
|
40
|
+
encoding: 'utf8',
|
|
41
|
+
timeout: 10000,
|
|
42
|
+
killSignal: 'SIGKILL',
|
|
43
|
+
maxBuffer: 1 << 20,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Fail closed unless the tools user exists, is reachable without a password, and cannot read state. */
|
|
47
|
+
export function probeToolsUser(
|
|
48
|
+
user: string,
|
|
49
|
+
stateDir: string,
|
|
50
|
+
prefix = ['sudo', '-n', '-u', user, '--'],
|
|
51
|
+
): ToolIdentity {
|
|
52
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(user))
|
|
53
|
+
throw new Error('--tools-user must be a plain Unix user name');
|
|
54
|
+
if (process.platform !== 'linux' && prefix[0] === 'sudo')
|
|
55
|
+
throw new Error('--tools-user is supported on Linux hosts only');
|
|
56
|
+
const id = run([...prefix, '/usr/bin/id', '-u']);
|
|
57
|
+
const uid = Number(String(id.stdout).trim());
|
|
58
|
+
if (id.status !== 0 || !Number.isSafeInteger(uid))
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Cannot run as tools user ${user} (sudo -n -u ${user} failed); see docs/security.md host setup`,
|
|
61
|
+
);
|
|
62
|
+
if (uid === 0 || uid === process.getuid?.())
|
|
63
|
+
throw new Error('The tools user must be an unprivileged user distinct from the service user');
|
|
64
|
+
if (run([...prefix, '/usr/bin/test', '-r', stateDir]).status === 0)
|
|
65
|
+
throw new Error(`Tools user ${user} can read the Salaam state directory; restrict it to 0700`);
|
|
66
|
+
return { mode: 'tools-user', user, uid, prefix, warning: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function toolArgv(identity: ToolIdentity, argv: string[]): string[] {
|
|
70
|
+
return [...identity.prefix, ...argv];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Kill a tool process group owned by the tools user; the daemon cannot signal another uid. */
|
|
74
|
+
export function killToolGroup(identity: ToolIdentity, pgid: number): void {
|
|
75
|
+
if (identity.mode !== 'tools-user' || !Number.isSafeInteger(pgid) || pgid < 2) return;
|
|
76
|
+
run(toolArgv(identity, ['/bin/kill', '-KILL', '--', '-' + pgid]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const here = fileURLToPath(import.meta.url);
|
|
80
|
+
/** Source runs (tests) use the built helper; packages ship it beside this file. */
|
|
81
|
+
export const EDIT_HELPER = here.endsWith('.ts')
|
|
82
|
+
? join(dirname(here), '..', 'dist/src/local-edit-helper.js')
|
|
83
|
+
: join(dirname(here), 'local-edit-helper.js');
|
|
84
|
+
|
|
85
|
+
export interface ToolEdit extends LocalEdit {
|
|
86
|
+
remove?: boolean;
|
|
87
|
+
}
|
|
88
|
+
/** Apply reviewed edits as the tools user. Reports each applied path; any failure throws. */
|
|
89
|
+
export function applyEditsAsTools(
|
|
90
|
+
identity: ToolIdentity,
|
|
91
|
+
root: string,
|
|
92
|
+
edits: ToolEdit[],
|
|
93
|
+
applied: (path: string) => void,
|
|
94
|
+
): void {
|
|
95
|
+
if (!existsSync(EDIT_HELPER)) throw new ServiceError('tool_identity', 'Edit helper is missing');
|
|
96
|
+
const result = run(
|
|
97
|
+
toolArgv(identity, [process.execPath, EDIT_HELPER, root]),
|
|
98
|
+
JSON.stringify(edits),
|
|
99
|
+
);
|
|
100
|
+
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
101
|
+
if (line.startsWith('applied ')) applied(JSON.parse(line.slice(8)) as string);
|
|
102
|
+
}
|
|
103
|
+
if (result.status !== 0)
|
|
104
|
+
throw new ServiceError(
|
|
105
|
+
'tool_identity',
|
|
106
|
+
`Edit as ${identity.user} failed: ${String(result.stderr ?? '')
|
|
107
|
+
.trim()
|
|
108
|
+
.slice(0, 400)}`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { humanTool, safeText } from './tui-theme.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One compact line per tool call in the conversation (trial polish #9): what ran and how it
|
|
5
|
+
* ended, e.g. `Edit src/x.ts +3 −1` or `Bash npm test ✓ exit 0` with the last output lines.
|
|
6
|
+
* Built only from the recorded tool activity; it never reads the workspace.
|
|
7
|
+
*/
|
|
8
|
+
export interface ToolLine {
|
|
9
|
+
state: 'running' | 'finished' | 'failed';
|
|
10
|
+
label: string;
|
|
11
|
+
/** The last output lines of a finished command (at most `tail` lines). */
|
|
12
|
+
tail: string[];
|
|
13
|
+
/** A command's full output as text, for the transcript; absent when only the raw record exists. */
|
|
14
|
+
full?: string;
|
|
15
|
+
}
|
|
16
|
+
const lineCount = (text: string) => (text === '' ? 0 : text.replace(/\n$/, '').split('\n').length);
|
|
17
|
+
function short(text: string, max = 60): string {
|
|
18
|
+
const first = safeText(text).split('\n')[0]!.trim();
|
|
19
|
+
return first.length > max ? first.slice(0, max - 1) + '…' : first;
|
|
20
|
+
}
|
|
21
|
+
/** The recorded result of a local command: its exit code and output, even from a truncated record. */
|
|
22
|
+
function commandResult(output?: string): { exit?: number | null; text?: string } {
|
|
23
|
+
if (!output) return {};
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(output) as { exit_code?: number | null; output?: string };
|
|
26
|
+
return { exit: parsed.exit_code, text: parsed.output };
|
|
27
|
+
} catch {
|
|
28
|
+
const exit = /"exit_code":(-?\d+|null)/.exec(output)?.[1];
|
|
29
|
+
return exit === undefined ? {} : { exit: exit === 'null' ? null : Number(exit) };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function toolLine(
|
|
33
|
+
tool: string,
|
|
34
|
+
input: string | undefined,
|
|
35
|
+
finished: { failed?: boolean; output?: string } | undefined,
|
|
36
|
+
tail = 3,
|
|
37
|
+
): ToolLine {
|
|
38
|
+
const state = !finished ? 'running' : finished.failed ? 'failed' : 'finished';
|
|
39
|
+
let args: {
|
|
40
|
+
action?: string;
|
|
41
|
+
command?: string;
|
|
42
|
+
background?: boolean;
|
|
43
|
+
edits?: {
|
|
44
|
+
path?: string;
|
|
45
|
+
content?: string;
|
|
46
|
+
sha256?: string | null;
|
|
47
|
+
replacements?: { old: string; new: string }[];
|
|
48
|
+
}[];
|
|
49
|
+
} = {};
|
|
50
|
+
try {
|
|
51
|
+
args = JSON.parse(input ?? '{}');
|
|
52
|
+
} catch {}
|
|
53
|
+
if (tool === 'salaam_local' && args.action === 'edit' && Array.isArray(args.edits)) {
|
|
54
|
+
let added = 0,
|
|
55
|
+
removed = 0,
|
|
56
|
+
created = false;
|
|
57
|
+
for (const edit of args.edits) {
|
|
58
|
+
if (edit.replacements?.length)
|
|
59
|
+
for (const r of edit.replacements) {
|
|
60
|
+
added += lineCount(r.new);
|
|
61
|
+
removed += lineCount(r.old);
|
|
62
|
+
}
|
|
63
|
+
else if (typeof edit.content === 'string') {
|
|
64
|
+
added += lineCount(edit.content);
|
|
65
|
+
if (edit.sha256 === null) created = true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const what =
|
|
69
|
+
args.edits.length === 1
|
|
70
|
+
? safeText(String(args.edits[0]!.path ?? ''))
|
|
71
|
+
: `${args.edits.length} files`;
|
|
72
|
+
return {
|
|
73
|
+
state,
|
|
74
|
+
label: `Edit ${what} +${added} −${removed}${created ? ' (new)' : ''}`,
|
|
75
|
+
tail: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (tool === 'salaam_local' && args.action === 'command' && typeof args.command === 'string') {
|
|
79
|
+
const result = commandResult(finished?.output);
|
|
80
|
+
const ended =
|
|
81
|
+
!finished || args.background
|
|
82
|
+
? args.background && finished
|
|
83
|
+
? ' · background'
|
|
84
|
+
: ''
|
|
85
|
+
: result.exit === 0
|
|
86
|
+
? ' ✓ exit 0'
|
|
87
|
+
: result.exit === undefined || result.exit === null
|
|
88
|
+
? finished.failed
|
|
89
|
+
? ' × did not finish'
|
|
90
|
+
: ''
|
|
91
|
+
: ` × exit ${result.exit}`;
|
|
92
|
+
const lines = (result.text ?? '')
|
|
93
|
+
.replace(/\n+$/, '')
|
|
94
|
+
.split('\n')
|
|
95
|
+
.filter((l) => l.trim() !== '');
|
|
96
|
+
return {
|
|
97
|
+
state:
|
|
98
|
+
finished && result.exit !== undefined && result.exit !== null && result.exit !== 0
|
|
99
|
+
? 'failed'
|
|
100
|
+
: state,
|
|
101
|
+
label: `Bash ${short(args.command)}${ended}`,
|
|
102
|
+
tail: finished ? lines.slice(-tail).map((l) => short(l, 120)) : [],
|
|
103
|
+
...(result.text !== undefined ? { full: result.text.replace(/\n+$/, '') } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { state, label: humanTool(tool, input), tail: [] };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The verb the progress line shows for what the agent is doing now. */
|
|
110
|
+
export function activityVerb(running?: ToolLine): string {
|
|
111
|
+
if (!running) return 'Thinking';
|
|
112
|
+
if (running.label.startsWith('Edit ')) return 'Editing';
|
|
113
|
+
if (running.label.startsWith('Bash ')) return 'Running ' + running.label.slice(5);
|
|
114
|
+
return running.label;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const SPIN = ['◐', '◓', '◑', '◒'];
|
|
118
|
+
/** The live progress line while a turn runs: spinner, verb, elapsed seconds, tokens, interrupt hint. */
|
|
119
|
+
export function progressLine(
|
|
120
|
+
verb: string,
|
|
121
|
+
startedAt: number,
|
|
122
|
+
now: number,
|
|
123
|
+
tokens?: string,
|
|
124
|
+
): string {
|
|
125
|
+
const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
|
|
126
|
+
const elapsed = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
127
|
+
return [
|
|
128
|
+
SPIN[seconds % SPIN.length] + ' ' + safeText(verb) + '…',
|
|
129
|
+
elapsed,
|
|
130
|
+
...(tokens ? [tokens + ' tokens'] : []),
|
|
131
|
+
'esc to interrupt',
|
|
132
|
+
].join(' · ');
|
|
133
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** /classifier (operator 2026-10-31): the command reviewer has its own model selection. */
|
|
2
|
+
export interface ClassifierView {
|
|
3
|
+
configured: boolean;
|
|
4
|
+
enabled: boolean;
|
|
5
|
+
choice: string;
|
|
6
|
+
pinned: boolean;
|
|
7
|
+
model?: string;
|
|
8
|
+
on_unavailable?: string;
|
|
9
|
+
options: { name: string; provider: string; model: string }[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Picker rows: every configured reviewer, then Off. Empty when there is nothing to choose. */
|
|
13
|
+
export function classifierItems(v: ClassifierView) {
|
|
14
|
+
if (!v.configured || v.pinned) return [];
|
|
15
|
+
const mark = (on: boolean) => (on ? '● ' : '○ ');
|
|
16
|
+
return [
|
|
17
|
+
...v.options.map((o) => ({
|
|
18
|
+
value: o.name,
|
|
19
|
+
label: mark(v.choice === o.name) + o.name,
|
|
20
|
+
description: `${o.provider} · ${o.model}`,
|
|
21
|
+
})),
|
|
22
|
+
{ value: 'off', label: mark(v.choice === 'off') + 'Off', description: 'Rules only' },
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function classifierNotice(v: ClassifierView): string {
|
|
27
|
+
if (!v.configured && v.choice === 'off' && !v.pinned)
|
|
28
|
+
return 'No command reviewer is configured; set --danger-classifier-config on the service.';
|
|
29
|
+
const what = v.enabled
|
|
30
|
+
? `Command reviewer ${v.choice} (${v.model}) is on for this session` +
|
|
31
|
+
` (if it does not answer: ${v.on_unavailable === 'ask' ? 'ask' : 'rules only'})`
|
|
32
|
+
: 'Command reviewer is off for this session';
|
|
33
|
+
return (
|
|
34
|
+
what +
|
|
35
|
+
(v.pinned ? ', pinned by this host' : '') +
|
|
36
|
+
'. It can only make a command ask, never let one run.'
|
|
37
|
+
);
|
|
38
|
+
}
|