orizu 0.5.7 → 0.5.9
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/README.md +13 -2
- package/dist/credentials.js +44 -0
- package/dist/help.js +77 -7
- package/dist/hosted-bootstrap.js +45 -0
- package/dist/hosted-harness-opencode.js +21 -1
- package/dist/hosted-harness.js +9 -0
- package/dist/hosted-harvest.js +121 -0
- package/dist/hosted-headless.js +163 -0
- package/dist/hosted-loop.js +119 -5
- package/dist/hosted-run-event-sink.js +21 -0
- package/dist/hosted-runtime-assets.js +27 -0
- package/dist/hosted-session-cli.js +51 -9
- package/dist/http.js +16 -1
- package/dist/index.js +54 -86
- package/dist/manifests-cli.js +26 -4
- package/dist/report-comments-cli.js +325 -0
- package/dist/skill-installer.js +16 -1
- package/package.json +1 -1
- package/src/credentials.ts +46 -0
- package/src/help.ts +77 -7
- package/src/hosted-bootstrap.ts +52 -0
- package/src/hosted-harness-opencode.ts +24 -2
- package/src/hosted-harness.ts +9 -0
- package/src/hosted-harvest.ts +164 -0
- package/src/hosted-headless.ts +178 -0
- package/src/hosted-loop.ts +133 -4
- package/src/hosted-run-event-sink.ts +28 -0
- package/src/hosted-runtime-assets.ts +31 -0
- package/src/hosted-session-cli.ts +50 -9
- package/src/http.ts +23 -1
- package/src/index.ts +74 -156
- package/src/manifests-cli.ts +28 -4
- package/src/report-comments-cli.ts +524 -0
- package/src/skill-installer.ts +18 -1
- package/vendor/skills/orizu-cli/SKILL.md +9 -2
- package/vendor/skills/orizu-cli/references/prompt-control-plane.md +1 -1
package/README.md
CHANGED
|
@@ -174,8 +174,15 @@ orizu logout
|
|
|
174
174
|
<td><code>orizu prompts archive <promptIdOrName> --project ops-eval/support-qa</code><br><code>orizu prompts restore <promptIdOrName> --project ops-eval/support-qa</code></td>
|
|
175
175
|
</tr>
|
|
176
176
|
<tr>
|
|
177
|
-
<td>
|
|
178
|
-
|
|
177
|
+
<td colspan="2"><strong>Report comments</strong></td>
|
|
178
|
+
</tr>
|
|
179
|
+
<tr>
|
|
180
|
+
<td>List report comments</td>
|
|
181
|
+
<td><code>orizu comments list --prompt <promptIdOrName> --project ops-eval/support-qa</code><br><code>orizu comments list --run <runId></code><br><code>orizu comments list --task <taskId></code></td>
|
|
182
|
+
</tr>
|
|
183
|
+
<tr>
|
|
184
|
+
<td>Add or update comments</td>
|
|
185
|
+
<td><code>orizu comments add --run <runId> --body @comment.md --anchor "Summary" --lines 4:6</code><br><code>orizu comments reply <commentId> --body "Fixed"</code><br><code>orizu comments resolve <commentId></code></td>
|
|
179
186
|
</tr>
|
|
180
187
|
<tr>
|
|
181
188
|
<td colspan="2"><strong>Datasets</strong></td>
|
|
@@ -295,6 +302,10 @@ orizu logout
|
|
|
295
302
|
<td>Upload a task report</td>
|
|
296
303
|
<td><code>orizu tasks report set --task <taskId> --report-file ./report.md</code></td>
|
|
297
304
|
</tr>
|
|
305
|
+
<tr>
|
|
306
|
+
<td>Read a task report</td>
|
|
307
|
+
<td><code>orizu tasks report get --task <taskId></code></td>
|
|
308
|
+
</tr>
|
|
298
309
|
<tr>
|
|
299
310
|
<td>Resume a task</td>
|
|
300
311
|
<td><code>orizu tasks unpause --task <taskId></code></td>
|
package/dist/credentials.js
CHANGED
|
@@ -162,6 +162,50 @@ export function getActiveBaseUrl() {
|
|
|
162
162
|
const config = loadCredentialsConfig();
|
|
163
163
|
return config?.activeBaseUrl || null;
|
|
164
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* In-sandbox bearer resolution (ALI-1044). Resolves a bearer supplied out-of-band
|
|
167
|
+
* to the hosted agent, WITHOUT consulting credentials.json:
|
|
168
|
+
* 1. `ORIZU_TOKEN` — an explicit bearer in the environment (wins if present).
|
|
169
|
+
* 2. `ORIZU_TOKEN_FILE` — an absolute path to a 0600 file whose TRIMMED contents
|
|
170
|
+
* are the bearer.
|
|
171
|
+
* Returns null when neither is set, so callers fall through to credentials.json.
|
|
172
|
+
*
|
|
173
|
+
* The token file is read FRESH on EVERY call and never cached: the hosted loop
|
|
174
|
+
* ROTATES the file underneath a long-lived agent process, so caching the value
|
|
175
|
+
* would pin a stale (soon-expired) bearer. The token is never logged, echoed, or
|
|
176
|
+
* passed via argv — only returned to the caller for a single request's header.
|
|
177
|
+
*
|
|
178
|
+
* A set-but-unreadable/empty `ORIZU_TOKEN_FILE` throws a clear error rather than
|
|
179
|
+
* returning null, so a misconfigured sandbox fails loudly instead of silently
|
|
180
|
+
* falling back to (absent) credentials.json and emitting a confusing "not logged
|
|
181
|
+
* in" message.
|
|
182
|
+
*/
|
|
183
|
+
export function resolveEnvBearerToken() {
|
|
184
|
+
const explicit = process.env.ORIZU_TOKEN;
|
|
185
|
+
if (explicit && explicit.trim()) {
|
|
186
|
+
return explicit.trim();
|
|
187
|
+
}
|
|
188
|
+
const tokenFile = process.env.ORIZU_TOKEN_FILE;
|
|
189
|
+
if (tokenFile && tokenFile.length > 0) {
|
|
190
|
+
if (!existsSync(tokenFile)) {
|
|
191
|
+
throw new Error(`ORIZU_TOKEN_FILE is set to ${tokenFile} but no such file exists.`);
|
|
192
|
+
}
|
|
193
|
+
let raw;
|
|
194
|
+
try {
|
|
195
|
+
raw = readFileSync(tokenFile, 'utf8');
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const detail = isNodeError(error) ? error.message : String(error);
|
|
199
|
+
throw new Error(`Failed to read ORIZU_TOKEN_FILE (${tokenFile}): ${detail}`);
|
|
200
|
+
}
|
|
201
|
+
const token = raw.trim();
|
|
202
|
+
if (!token) {
|
|
203
|
+
throw new Error(`ORIZU_TOKEN_FILE (${tokenFile}) is empty.`);
|
|
204
|
+
}
|
|
205
|
+
return token;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
165
209
|
export function setActiveBaseUrl(baseUrl) {
|
|
166
210
|
const config = loadCredentialsConfigForWrite();
|
|
167
211
|
config.activeBaseUrl = baseUrl;
|
package/dist/help.js
CHANGED
|
@@ -11,6 +11,7 @@ const GROUPS = [
|
|
|
11
11
|
{ name: 'Teams', summary: 'Manage teams and team memberships.' },
|
|
12
12
|
{ name: 'Projects', summary: 'Manage projects inside teams.' },
|
|
13
13
|
{ name: 'Prompts and judges', summary: 'Work with versioned prompt and judge artifacts.' },
|
|
14
|
+
{ name: 'Report comments', summary: 'Review and mutate anchored report comments across prompt, optimization, and task reports.' },
|
|
14
15
|
{ name: 'Scorers and runners', summary: 'Register scorers, push runnable artifacts, execute runners, and submit scores.' },
|
|
15
16
|
{ name: 'Optimizations', summary: 'Start, run, export, and finalize optimization runs.' },
|
|
16
17
|
{ name: 'Apps', summary: 'Create, preview, update, inspect, and export review apps.' },
|
|
@@ -57,6 +58,67 @@ export const COMMAND_DOCS = [
|
|
|
57
58
|
summary: 'Submit an optimization event payload.',
|
|
58
59
|
group: 'Optimizations',
|
|
59
60
|
},
|
|
61
|
+
{
|
|
62
|
+
path: ['comments', 'list'],
|
|
63
|
+
usage: 'orizu comments list (--prompt <id-or-name> --project <team/project> [--label <label> | --version <id>] | --run <run-id> | --task <task-id>) [--json]',
|
|
64
|
+
summary: 'List report comment threads with open/resolved status and replies.',
|
|
65
|
+
group: 'Report comments',
|
|
66
|
+
options: [
|
|
67
|
+
{ name: '--prompt <id-or-name>', help: 'Prompt report target. Requires --project.' },
|
|
68
|
+
{ name: '--project <team/project>', help: 'Project slug for prompt targets.' },
|
|
69
|
+
{ name: '--label <label>', help: 'Prompt version label to comment on.' },
|
|
70
|
+
{ name: '--version <id>', help: 'Prompt version ID to comment on.' },
|
|
71
|
+
{ name: '--run <run-id>', help: 'Optimization run report target.' },
|
|
72
|
+
{ name: '--task <task-id>', help: 'Task report target.' },
|
|
73
|
+
{ name: '--json', help: 'Emit the full comments payload.' },
|
|
74
|
+
],
|
|
75
|
+
examples: [
|
|
76
|
+
'orizu comments list --prompt Generator --project core/evals --label production',
|
|
77
|
+
'orizu comments list --run <run-id>',
|
|
78
|
+
'orizu comments list --task <task-id>',
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
path: ['comments', 'add'],
|
|
83
|
+
usage: 'orizu comments add (--prompt <id-or-name> --project <team/project> [--label <label> | --version <id>] | --run <run-id> | --task <task-id>) --body <text|@file> [--anchor <text>] [--lines <start:end>] [--via <name>] [--json]',
|
|
84
|
+
summary: 'Add an anchored top-level comment to a report.',
|
|
85
|
+
group: 'Report comments',
|
|
86
|
+
options: [
|
|
87
|
+
{ name: '--body <text|@file>', help: 'Comment body or @file reference.', required: true },
|
|
88
|
+
{ name: '--anchor <text>', help: 'Quoted report text for the comment anchor.' },
|
|
89
|
+
{ name: '--lines <start:end>', help: 'One-based inclusive line range for the anchor.' },
|
|
90
|
+
{ name: '--via <name>', help: 'Tool or agent name to show in attribution.' },
|
|
91
|
+
{ name: '--json', help: 'Emit the created comment payload.' },
|
|
92
|
+
],
|
|
93
|
+
examples: [
|
|
94
|
+
'orizu comments add --run <run-id> --body @comment.md --anchor "Score summary" --lines 4:6',
|
|
95
|
+
'orizu comments add --task <task-id> --body "Clarify this finding" --via Codex',
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
path: ['comments', 'reply'],
|
|
100
|
+
usage: 'orizu comments reply <comment-id> --body <text|@file> [--via <name>] [--json]',
|
|
101
|
+
summary: 'Reply to a top-level report comment thread.',
|
|
102
|
+
group: 'Report comments',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
path: ['comments', 'resolve'],
|
|
106
|
+
usage: 'orizu comments resolve <comment-id> [--json]',
|
|
107
|
+
summary: 'Resolve a top-level report comment thread.',
|
|
108
|
+
group: 'Report comments',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
path: ['comments', 'unresolve'],
|
|
112
|
+
usage: 'orizu comments unresolve <comment-id> [--json]',
|
|
113
|
+
summary: 'Reopen a resolved report comment thread.',
|
|
114
|
+
group: 'Report comments',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: ['comments', 'edit'],
|
|
118
|
+
usage: 'orizu comments edit <comment-id> --body <text|@file> [--json]',
|
|
119
|
+
summary: 'Edit one of your report comments or replies.',
|
|
120
|
+
group: 'Report comments',
|
|
121
|
+
},
|
|
60
122
|
{
|
|
61
123
|
path: ['setup'],
|
|
62
124
|
usage: 'orizu setup [--team <slug>] [--agent <claude|codex>]... [--workspace [path]|--no-workspace] [--validate] [--fix] [--no-symlinks] [--verbose] [--no-install] [--handoff|--no-handoff] [--launch <claude|codex>] [--skip-login] [--yes] [--dry-run] [--no-input|--non-interactive] [--json]',
|
|
@@ -100,7 +162,7 @@ export const COMMAND_DOCS = [
|
|
|
100
162
|
},
|
|
101
163
|
{
|
|
102
164
|
path: ['install-skill'],
|
|
103
|
-
usage: 'orizu install-skill [--agent <claude|codex>]... [--scope global|project] [--mode auto|link|copy] [--target <target>]... [--yes] [--dry-run]',
|
|
165
|
+
usage: 'orizu install-skill [--agent <claude|codex|opencode>]... [--scope global|project] [--mode auto|link|copy] [--target <target>]... [--yes] [--dry-run]',
|
|
104
166
|
summary: 'Install or repair the Orizu coding-agent skill.',
|
|
105
167
|
group: 'Agent setup',
|
|
106
168
|
aliases: [['skills', 'install']],
|
|
@@ -248,12 +310,6 @@ export const COMMAND_DOCS = [
|
|
|
248
310
|
summary: 'Restore an archived prompt artifact to active status.',
|
|
249
311
|
group: 'Prompts and judges',
|
|
250
312
|
},
|
|
251
|
-
{
|
|
252
|
-
path: ['prompts', 'comments'],
|
|
253
|
-
usage: 'orizu prompts comments <prompt-id-or-name> --project <team/project> [--label <label> | --version <id>] [--json]',
|
|
254
|
-
summary: 'List prompt comment threads with open/resolved status and replies.',
|
|
255
|
-
group: 'Prompts and judges',
|
|
256
|
-
},
|
|
257
313
|
{
|
|
258
314
|
path: ['prompts', 'pull'],
|
|
259
315
|
usage: 'orizu prompts pull <prompt-id-or-name> --project <team/project> --out <dir> [--label <label> | --version <id>] [--json]',
|
|
@@ -538,6 +594,20 @@ export const COMMAND_DOCS = [
|
|
|
538
594
|
'orizu tasks report upload --task <taskId> --report @./report.md',
|
|
539
595
|
],
|
|
540
596
|
},
|
|
597
|
+
{
|
|
598
|
+
path: ['tasks', 'report', 'get'],
|
|
599
|
+
usage: 'orizu tasks report get --task <taskId> [--json]',
|
|
600
|
+
summary: 'Read the Markdown report for a task.',
|
|
601
|
+
group: 'Tasks',
|
|
602
|
+
options: [
|
|
603
|
+
{ name: '--task <taskId>', help: 'Task ID whose report should be read.', required: true },
|
|
604
|
+
{ name: '--json', help: 'Emit the full report payload.' },
|
|
605
|
+
],
|
|
606
|
+
examples: [
|
|
607
|
+
'orizu tasks report get --task <taskId>',
|
|
608
|
+
'orizu tasks report get --task <taskId> --json',
|
|
609
|
+
],
|
|
610
|
+
},
|
|
541
611
|
{
|
|
542
612
|
path: ['tasks', 'pause'],
|
|
543
613
|
usage: 'orizu tasks pause --task <taskId>',
|
package/dist/hosted-bootstrap.js
CHANGED
|
@@ -265,6 +265,51 @@ export async function bootstrapHostedSandbox(opts) {
|
|
|
265
265
|
outputTail: tail(install.stdout, install.stderr),
|
|
266
266
|
});
|
|
267
267
|
}
|
|
268
|
+
// 4b — Stage the orizu-cli skill into the workspace so the agent discovers it
|
|
269
|
+
// (ALI-1044). Resolve the CLI's OWN vendored skill dir in-sandbox, preferring:
|
|
270
|
+
// (1) $ORIZU_SKILL_SOURCE_DIR — explicit override (also honored by the CLI's
|
|
271
|
+
// skill-installer; the local-sim rehearsal sets it),
|
|
272
|
+
// (2) `orizu skills path` — the packaged vendor/skills/orizu-cli of the
|
|
273
|
+
// globally-installed CLI (the production path),
|
|
274
|
+
// (3) `$(npm root -g)/orizu/vendor/skills/orizu-cli` — belt fallback.
|
|
275
|
+
// Then SYMLINK it under <workspaceDir>/.claude/skills/orizu-cli, falling back
|
|
276
|
+
// to a copy if the symlink cannot be created. Non-fatal + recorded, exactly
|
|
277
|
+
// like the CLI install. Resolved paths stay in shell vars (never interpolated),
|
|
278
|
+
// so they cannot inject into the command.
|
|
279
|
+
currentStep = 'skill_staged';
|
|
280
|
+
const skillSkillsDirRel = `${workspaceDir}/.claude/skills`;
|
|
281
|
+
const stage = await session.exec([
|
|
282
|
+
`mkdir -p ${skillSkillsDirRel}`,
|
|
283
|
+
// The staged skill is bootstrap-injected RUNTIME scaffolding (a symlink to
|
|
284
|
+
// the sandbox-local CLI vendor dir), NOT the agent's work. Exclude it
|
|
285
|
+
// repo-LOCALLY (.git/info/exclude — invisible to the diff, never committed)
|
|
286
|
+
// so auto-harvest's `git add -A` can't sweep it into the session branch and
|
|
287
|
+
// (ALI-1051) auto-apply a broken symlink to the customer's main.
|
|
288
|
+
`if [ -d ${workspaceDir}/.git ]; then printf '%s\\n' '/.claude/skills/' >> ${workspaceDir}/.git/info/exclude; fi`,
|
|
289
|
+
`src="${'${ORIZU_SKILL_SOURCE_DIR:-}'}"`,
|
|
290
|
+
`if [ -z "$src" ] || [ ! -d "$src" ]; then src="$(orizu skills path 2>/dev/null || true)"; fi`,
|
|
291
|
+
`if [ -z "$src" ] || [ ! -d "$src" ]; then r="$(npm root -g 2>/dev/null || true)"; if [ -n "$r" ] && [ -d "$r/orizu/vendor/skills/orizu-cli" ]; then src="$r/orizu/vendor/skills/orizu-cli"; fi; fi`,
|
|
292
|
+
`if [ -z "$src" ] || [ ! -d "$src" ]; then echo "NO_SOURCE"; exit 0; fi`,
|
|
293
|
+
`dest="${skillSkillsDirRel}/orizu-cli"`,
|
|
294
|
+
`rm -rf "$dest"`,
|
|
295
|
+
`if ln -s "$src" "$dest" 2>/dev/null; then echo "SYMLINK $src"; else cp -R "$src" "$dest" && echo "COPY $src"; fi`,
|
|
296
|
+
].join('\n'));
|
|
297
|
+
const stageOut = stage.stdout.trim();
|
|
298
|
+
const stagedMethod = stageOut.startsWith('SYMLINK')
|
|
299
|
+
? 'symlink'
|
|
300
|
+
: stageOut.startsWith('COPY')
|
|
301
|
+
? 'copy'
|
|
302
|
+
: null;
|
|
303
|
+
const stagedOk = stage.exitCode === 0 && stagedMethod !== null;
|
|
304
|
+
record('skill_staged', stagedOk, stagedOk
|
|
305
|
+
? `orizu-cli skill staged (${stagedMethod})`
|
|
306
|
+
: `skill staging unresolved: ${stageOut || `exit ${stage.exitCode}`}`);
|
|
307
|
+
await sink.append('skill_staged', {
|
|
308
|
+
ok: stagedOk,
|
|
309
|
+
method: stagedMethod,
|
|
310
|
+
dest: `${skillSkillsDirRel}/orizu-cli`,
|
|
311
|
+
outputTail: tail(stage.stdout, stage.stderr),
|
|
312
|
+
});
|
|
268
313
|
// 5 — Customer setup hook (fresh-boot only, non-fatal, output captured).
|
|
269
314
|
// P3-a: for enforced-egress providers the host DEFERS this hook to the loop,
|
|
270
315
|
// which runs it AFTER the egress canary proves the firewall is live — so the
|
|
@@ -686,6 +686,26 @@ function errorEvent(messageId, error) {
|
|
|
686
686
|
const message = error instanceof Error ? error.message : String(error);
|
|
687
687
|
return { kind: 'error', messageId, critical: true, payload: { error: message } };
|
|
688
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Tools DENIED in a hosted headless run (ALI-1037). OpenCode exposes a `question`
|
|
691
|
+
* permission key (docs/permissions) that gates the interactive "ask the user a
|
|
692
|
+
* question" tool. In an unattended sandbox there is no human to answer it, and
|
|
693
|
+
* OpenCode's only external reply endpoint — `POST /session/{id}/permissions/{permID}`
|
|
694
|
+
* — takes a BOOLEAN allow/deny, so it cannot select one of the tool's multiple
|
|
695
|
+
* options anyway. Denying the tool makes the model's ask fail fast (surfaced back
|
|
696
|
+
* to it as a tool error) so it proceeds autonomously per the standing preamble
|
|
697
|
+
* (HOSTED_TASK_PREAMBLE), instead of blocking the run forever. This is the
|
|
698
|
+
* documented fallback path in ALI-1037 (answer-externally is not cleanly possible).
|
|
699
|
+
*/
|
|
700
|
+
export const HEADLESS_DENIED_TOOLS = ['question'];
|
|
701
|
+
/** The default headless permission map: allow everything, deny the interactive
|
|
702
|
+
* question tool (see HEADLESS_DENIED_TOOLS). */
|
|
703
|
+
export function defaultHeadlessPermission() {
|
|
704
|
+
const permission = { '*': { '*': 'allow' } };
|
|
705
|
+
for (const tool of HEADLESS_DENIED_TOOLS)
|
|
706
|
+
permission[tool] = 'deny';
|
|
707
|
+
return permission;
|
|
708
|
+
}
|
|
689
709
|
/**
|
|
690
710
|
* Build the `OPENCODE_CONFIG_CONTENT` payload OpenCode reads at boot (mirrors
|
|
691
711
|
* OpenInspect's `start_opencode`). Kept pure so the launch command and the
|
|
@@ -694,7 +714,7 @@ function errorEvent(messageId, error) {
|
|
|
694
714
|
export function buildOpenCodeConfigContent(opts) {
|
|
695
715
|
return JSON.stringify({
|
|
696
716
|
model: opts.model,
|
|
697
|
-
permission: opts.permission ??
|
|
717
|
+
permission: opts.permission ?? defaultHeadlessPermission(),
|
|
698
718
|
});
|
|
699
719
|
}
|
|
700
720
|
const OPENCODE_READY_TIMEOUT_MS = 180_000;
|
package/dist/hosted-harness.js
CHANGED
|
@@ -46,6 +46,15 @@ export const HARNESS_EVENT_KINDS = {
|
|
|
46
46
|
// and never terminal, so they append like any structural event.
|
|
47
47
|
egress_blocked: 'egress_blocked',
|
|
48
48
|
egress_allowed: 'egress_allowed',
|
|
49
|
+
// Durability + headless (ALI-1036 / ALI-1037). NOT produced by a harness — the
|
|
50
|
+
// in-sandbox loop emits these through the SAME single-writer run-event sink:
|
|
51
|
+
// the end-of-run auto-harvest result (work_persisted/work_none/work_persist_failed)
|
|
52
|
+
// and the headless question auto-handling record (question_auto_answered). They
|
|
53
|
+
// are ordinary structural events (never terminal), appended like any other.
|
|
54
|
+
work_persisted: 'work_persisted',
|
|
55
|
+
work_none: 'work_none',
|
|
56
|
+
work_persist_failed: 'work_persist_failed',
|
|
57
|
+
question_auto_answered: 'question_auto_answered',
|
|
49
58
|
error: 'error',
|
|
50
59
|
execution_complete: 'execution_complete',
|
|
51
60
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-harvest (ALI-1036) — deterministic, loop-side durability.
|
|
3
|
+
*
|
|
4
|
+
* At the END of a hosted run (BOTH the success and failure paths — a failed
|
|
5
|
+
* run's partial work is still valuable), the in-sandbox loop calls
|
|
6
|
+
* `harvestWorkspace` to make sure nothing the agent produced is lost:
|
|
7
|
+
*
|
|
8
|
+
* 1. `git status --porcelain` (plumbing) decides if the clone is dirty. Ignored
|
|
9
|
+
* files never appear in porcelain output, so a checkpoint is skipped when the
|
|
10
|
+
* only changes are ignored files.
|
|
11
|
+
* 2. If dirty: `git add -A` then `git commit` attributed to the agent identity
|
|
12
|
+
* (AGENT_GIT_IDENTITY) with message `checkpoint: run <runId> auto-harvest`.
|
|
13
|
+
* 3. If there is anything unpushed (a fresh checkpoint OR pre-existing local
|
|
14
|
+
* commits the agent made but did not push), `git push` to the session branch
|
|
15
|
+
* (the repo-local credential helper configured by bootstrap authorizes it).
|
|
16
|
+
* 4. Return a typed outcome the loop records as a run event: `work_persisted`
|
|
17
|
+
* (sha + files), `work_none` (clean, nothing to push), or `work_persist_failed`
|
|
18
|
+
* (error). Harvest NEVER throws — a harvest failure must not change the run's
|
|
19
|
+
* terminal status; it is recorded and the run proceeds to terminal.
|
|
20
|
+
*
|
|
21
|
+
* The loop runs IN the sandbox, so git is driven with `child_process` (matching
|
|
22
|
+
* `defaultRunSetupHook` / `installOpenCodePinned` in `hosted-loop.ts`). `exec` is
|
|
23
|
+
* injectable so the whole thing is unit-testable with no real git.
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from 'child_process';
|
|
26
|
+
function defaultHarvestExec(workspaceDir) {
|
|
27
|
+
return (args) => {
|
|
28
|
+
const res = spawnSync('git', args, { cwd: workspaceDir, encoding: 'utf8' });
|
|
29
|
+
if (res.error) {
|
|
30
|
+
return { exitCode: 1, stdout: '', stderr: res.error.message };
|
|
31
|
+
}
|
|
32
|
+
return { exitCode: res.status ?? 1, stdout: res.stdout ?? '', stderr: res.stderr ?? '' };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function detail(res) {
|
|
36
|
+
const text = (res.stderr && res.stderr.trim()) || (res.stdout && res.stdout.trim()) || `exit ${res.exitCode}`;
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
function splitLines(text) {
|
|
40
|
+
return text
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map(line => line.trim())
|
|
43
|
+
.filter(line => line.length > 0);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Count commits present locally but not on the upstream tracking branch. Returns
|
|
47
|
+
* 0 when there is no upstream (`@{u}` unresolvable) — with no upstream a push
|
|
48
|
+
* could not land anyway, so "nothing to push" is the safe reading.
|
|
49
|
+
*/
|
|
50
|
+
function countUnpushed(exec) {
|
|
51
|
+
const res = exec(['rev-list', '--count', '@{u}..HEAD']);
|
|
52
|
+
if (res.exitCode !== 0)
|
|
53
|
+
return 0;
|
|
54
|
+
const n = Number.parseInt(res.stdout.trim(), 10);
|
|
55
|
+
return Number.isFinite(n) ? n : 0;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Persist any uncommitted or unpushed work in the workspace. Deterministic and
|
|
59
|
+
* total: every path returns a `HarvestOutcome`, never throws.
|
|
60
|
+
*/
|
|
61
|
+
export function harvestWorkspace(opts) {
|
|
62
|
+
// Safety gate: never touch a real repo unless explicitly enabled or given an
|
|
63
|
+
// injected exec. Absent both, report nothing-to-do rather than run host git.
|
|
64
|
+
if (!opts.exec && !opts.enabled) {
|
|
65
|
+
return { kind: 'work_none' };
|
|
66
|
+
}
|
|
67
|
+
const exec = opts.exec ?? defaultHarvestExec(opts.workspaceDir);
|
|
68
|
+
try {
|
|
69
|
+
// Never harvest bootstrap-injected runtime scaffolding (ALI-1051): the
|
|
70
|
+
// .claude/skills symlink is a sandbox-local pointer, not the agent's work.
|
|
71
|
+
// Bootstrap also excludes it via .git/info/exclude; this pathspec is the
|
|
72
|
+
// belt-and-braces for any sandbox where that didn't run.
|
|
73
|
+
const excludeScaffold = [':(exclude).claude/skills/**', ':(exclude).claude/skills'];
|
|
74
|
+
const status = exec(['status', '--porcelain', '--', '.', ...excludeScaffold]);
|
|
75
|
+
if (status.exitCode !== 0) {
|
|
76
|
+
return { kind: 'work_persist_failed', error: `git status failed: ${detail(status)}` };
|
|
77
|
+
}
|
|
78
|
+
const dirty = status.stdout.trim().length > 0;
|
|
79
|
+
if (dirty) {
|
|
80
|
+
const add = exec(['add', '-A', '--', '.', ...excludeScaffold]);
|
|
81
|
+
if (add.exitCode !== 0) {
|
|
82
|
+
return { kind: 'work_persist_failed', error: `git add failed: ${detail(add)}` };
|
|
83
|
+
}
|
|
84
|
+
const commit = exec([
|
|
85
|
+
'-c',
|
|
86
|
+
`user.name=${opts.author.name}`,
|
|
87
|
+
'-c',
|
|
88
|
+
`user.email=${opts.author.email}`,
|
|
89
|
+
'commit',
|
|
90
|
+
'--author',
|
|
91
|
+
`${opts.author.name} <${opts.author.email}>`,
|
|
92
|
+
'-m',
|
|
93
|
+
`checkpoint: run ${opts.runId} auto-harvest`,
|
|
94
|
+
]);
|
|
95
|
+
if (commit.exitCode !== 0) {
|
|
96
|
+
return { kind: 'work_persist_failed', error: `git commit failed: ${detail(commit)}` };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Nothing to push when the tree was clean AND there are no local-only commits.
|
|
100
|
+
const unpushed = countUnpushed(exec);
|
|
101
|
+
if (!dirty && unpushed <= 0) {
|
|
102
|
+
return { kind: 'work_none' };
|
|
103
|
+
}
|
|
104
|
+
const headRes = exec(['rev-parse', 'HEAD']);
|
|
105
|
+
const sha = headRes.exitCode === 0 ? headRes.stdout.trim() : '';
|
|
106
|
+
// Files: the checkpoint commit's tree when we just committed, else everything
|
|
107
|
+
// spanned by the unpushed commits.
|
|
108
|
+
const filesRes = dirty
|
|
109
|
+
? exec(['diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'])
|
|
110
|
+
: exec(['diff', '--name-only', '@{u}..HEAD']);
|
|
111
|
+
const files = filesRes.exitCode === 0 ? splitLines(filesRes.stdout) : [];
|
|
112
|
+
const push = exec(['push']);
|
|
113
|
+
if (push.exitCode !== 0) {
|
|
114
|
+
return { kind: 'work_persist_failed', error: `git push failed: ${detail(push)}` };
|
|
115
|
+
}
|
|
116
|
+
return { kind: 'work_persisted', sha, files };
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return { kind: 'work_persist_failed', error: error instanceof Error ? error.message : String(error) };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless-run helpers (ALI-1037) for the in-sandbox loop.
|
|
3
|
+
*
|
|
4
|
+
* - `withIdleWatchdog`: wraps a harness event stream and aborts a prompt that
|
|
5
|
+
* makes NO progress (no harness event) for a bounded window, so a stalled
|
|
6
|
+
* agent can never hang a hosted run forever. On idle it invokes `onTimeout`
|
|
7
|
+
* (the loop aborts the harness) then throws `AgentStalledError`, which the
|
|
8
|
+
* loop turns into a `failed` terminal — AFTER running the end-of-run harvest.
|
|
9
|
+
*
|
|
10
|
+
* - `annotateHeadlessQuestions`: observability for the question-tool deny path.
|
|
11
|
+
* The interactive `question` tool is DENIED via OpenCode's permission map
|
|
12
|
+
* (see HEADLESS_DENIED_TOOLS in hosted-harness-opencode.ts), so a headless
|
|
13
|
+
* run never blocks on it. When the agent nonetheless attempts a `question`
|
|
14
|
+
* tool call, this wrapper emits a `question_auto_answered` event recording the
|
|
15
|
+
* question and the option that WOULD be recommended, so the run timeline shows
|
|
16
|
+
* the auto-handling. It touches only the normalized event stream — never the
|
|
17
|
+
* fragile OpenCode SSE transform.
|
|
18
|
+
*/
|
|
19
|
+
/** Thrown by `withIdleWatchdog` when the stream is idle past its timeout. */
|
|
20
|
+
export class AgentStalledError extends Error {
|
|
21
|
+
idleMs;
|
|
22
|
+
constructor(idleMs) {
|
|
23
|
+
super(`agent_stalled: no progress for ${formatIdleWindow(idleMs)}`);
|
|
24
|
+
this.name = 'AgentStalledError';
|
|
25
|
+
this.idleMs = idleMs;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Render the idle window for the error message. Minutes (the operator-facing
|
|
29
|
+
* unit; default window is 10m), rounded to one decimal so sub-minute test
|
|
30
|
+
* windows still read sensibly. */
|
|
31
|
+
export function formatIdleWindow(ms) {
|
|
32
|
+
const minutes = Math.round((ms / 60000) * 10) / 10;
|
|
33
|
+
return `${minutes}m`;
|
|
34
|
+
}
|
|
35
|
+
const IDLE_SENTINEL = Symbol('idle');
|
|
36
|
+
/**
|
|
37
|
+
* Wrap `source` so that if no value arrives within `timeoutMs`, `onTimeout` runs
|
|
38
|
+
* and `AgentStalledError` is thrown. The idle timer resets on EVERY value, so it
|
|
39
|
+
* measures gaps between events (progress), not total duration. The still-pending
|
|
40
|
+
* `next()` is not left as an unhandled rejection, and the underlying iterator is
|
|
41
|
+
* returned/closed on any early exit.
|
|
42
|
+
*/
|
|
43
|
+
export async function* withIdleWatchdog(source, opts) {
|
|
44
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
45
|
+
try {
|
|
46
|
+
for (;;) {
|
|
47
|
+
let timer;
|
|
48
|
+
const idle = new Promise(resolve => {
|
|
49
|
+
timer = setTimeout(() => resolve(IDLE_SENTINEL), opts.timeoutMs);
|
|
50
|
+
});
|
|
51
|
+
const nextResult = iterator.next();
|
|
52
|
+
let raced;
|
|
53
|
+
try {
|
|
54
|
+
raced = await Promise.race([nextResult, idle]);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
if (timer)
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
if (raced === IDLE_SENTINEL) {
|
|
61
|
+
// Swallow the abandoned next() so it never surfaces as an unhandled
|
|
62
|
+
// rejection once the underlying stream tears down.
|
|
63
|
+
void Promise.resolve(nextResult).catch(() => { });
|
|
64
|
+
await opts.onTimeout();
|
|
65
|
+
throw new AgentStalledError(opts.timeoutMs);
|
|
66
|
+
}
|
|
67
|
+
if (raced.done)
|
|
68
|
+
return;
|
|
69
|
+
yield raced.value;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
if (iterator.return) {
|
|
74
|
+
try {
|
|
75
|
+
await iterator.return();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// best-effort close
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// -- Headless question annotation --------------------------------------------
|
|
84
|
+
function optionText(option) {
|
|
85
|
+
if (typeof option === 'string')
|
|
86
|
+
return option;
|
|
87
|
+
if (option && typeof option === 'object') {
|
|
88
|
+
const rec = option;
|
|
89
|
+
for (const key of ['label', 'text', 'value', 'title', 'option', 'name']) {
|
|
90
|
+
if (typeof rec[key] === 'string')
|
|
91
|
+
return rec[key];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Parse the OpenCode `question` tool args into the question text and the option
|
|
98
|
+
* we would pick headless: the one labeled "(Recommended)" if present, else the
|
|
99
|
+
* first. Defensive against the tool's shape drifting (options as strings or
|
|
100
|
+
* objects; the question under any of several keys).
|
|
101
|
+
*/
|
|
102
|
+
export function parseHeadlessQuestion(args) {
|
|
103
|
+
const rec = args && typeof args === 'object' ? args : {};
|
|
104
|
+
const questions = Array.isArray(rec.questions) ? rec.questions : [];
|
|
105
|
+
const first = questions[0];
|
|
106
|
+
let question = '';
|
|
107
|
+
let options = [];
|
|
108
|
+
if (typeof first === 'string') {
|
|
109
|
+
question = first;
|
|
110
|
+
}
|
|
111
|
+
else if (first && typeof first === 'object') {
|
|
112
|
+
const q = first;
|
|
113
|
+
for (const key of ['question', 'prompt', 'text', 'title', 'message']) {
|
|
114
|
+
if (typeof q[key] === 'string') {
|
|
115
|
+
question = q[key];
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(q.options))
|
|
120
|
+
options = q.options;
|
|
121
|
+
}
|
|
122
|
+
if (options.length === 0 && Array.isArray(rec.options))
|
|
123
|
+
options = rec.options;
|
|
124
|
+
if (!question && typeof rec.question === 'string')
|
|
125
|
+
question = rec.question;
|
|
126
|
+
const texts = options.map(optionText).filter(text => text.length > 0);
|
|
127
|
+
const recommended = texts.find(text => /\(recommended\)/i.test(text));
|
|
128
|
+
const chosenOption = recommended ?? texts[0] ?? '';
|
|
129
|
+
return { question, chosenOption };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Pass every harness event through unchanged; when a `question` tool call/result
|
|
133
|
+
* is seen, additionally emit a `question_auto_answered` event (deduped per
|
|
134
|
+
* callId+message) recording the auto-handling. The tool is denied at the
|
|
135
|
+
* permission layer, so this is a record — not an interactive reply.
|
|
136
|
+
*/
|
|
137
|
+
export async function* annotateHeadlessQuestions(source) {
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
for await (const event of source) {
|
|
140
|
+
yield event;
|
|
141
|
+
if (event.kind !== 'tool_call' && event.kind !== 'tool_result')
|
|
142
|
+
continue;
|
|
143
|
+
const payload = event.payload;
|
|
144
|
+
if (payload.tool !== 'question')
|
|
145
|
+
continue;
|
|
146
|
+
const callId = typeof payload.callId === 'string' ? payload.callId : '';
|
|
147
|
+
const key = `${callId}:${event.messageId ?? ''}`;
|
|
148
|
+
if (seen.has(key))
|
|
149
|
+
continue;
|
|
150
|
+
seen.add(key);
|
|
151
|
+
const parsed = parseHeadlessQuestion(payload.args);
|
|
152
|
+
yield {
|
|
153
|
+
kind: 'question_auto_answered',
|
|
154
|
+
messageId: event.messageId,
|
|
155
|
+
payload: {
|
|
156
|
+
question: parsed.question,
|
|
157
|
+
chosenOption: parsed.chosenOption,
|
|
158
|
+
note: 'auto-answered: headless run, no human available',
|
|
159
|
+
resolution: 'denied_headless',
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|