@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2
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/LICENSE +21 -0
- package/README.md +80 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/integrations/skills/pappra/SKILL.md +68 -0
- package/assets/integrations/skills/pappra/references/hosts.md +30 -0
- package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
- package/assets/integrations/skills/pappra/references/operations.md +14 -0
- package/assets/manifest.json +4 -0
- package/dist/app/collaboration.d.ts +170 -0
- package/dist/app/collaboration.js +1650 -0
- package/dist/app/doctor.d.ts +9 -0
- package/dist/app/doctor.js +1030 -0
- package/dist/app/init.d.ts +143 -0
- package/dist/app/init.js +1111 -0
- package/dist/cli/commands/code.d.ts +16 -0
- package/dist/cli/commands/code.js +127 -0
- package/dist/cli/commands/collaboration.d.ts +13 -0
- package/dist/cli/commands/collaboration.js +186 -0
- package/dist/cli/commands/continuity.d.ts +29 -0
- package/dist/cli/commands/continuity.js +133 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +133 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +57 -0
- package/dist/cli/commands/host.d.ts +13 -0
- package/dist/cli/commands/host.js +125 -0
- package/dist/cli/commands/init.d.ts +51 -0
- package/dist/cli/commands/init.js +668 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +597 -0
- package/dist/code/budget.d.ts +14 -0
- package/dist/code/budget.js +39 -0
- package/dist/code/exec.d.ts +23 -0
- package/dist/code/exec.js +133 -0
- package/dist/code/identity.d.ts +13 -0
- package/dist/code/identity.js +214 -0
- package/dist/code/index.d.ts +6 -0
- package/dist/code/index.js +3 -0
- package/dist/code/providers/codegraph.d.ts +59 -0
- package/dist/code/providers/codegraph.js +567 -0
- package/dist/code/registration.d.ts +9 -0
- package/dist/code/registration.js +293 -0
- package/dist/code/service.d.ts +53 -0
- package/dist/code/service.js +633 -0
- package/dist/code/text.d.ts +3 -0
- package/dist/code/text.js +202 -0
- package/dist/collab/admission.d.ts +450 -0
- package/dist/collab/admission.js +797 -0
- package/dist/collab/canonical.d.ts +26 -0
- package/dist/collab/canonical.js +136 -0
- package/dist/collab/contracts.d.ts +73 -0
- package/dist/collab/contracts.js +76 -0
- package/dist/collab/envelope.d.ts +52 -0
- package/dist/collab/envelope.js +241 -0
- package/dist/collab/fingerprint.d.ts +7 -0
- package/dist/collab/fingerprint.js +58 -0
- package/dist/collab/handoff.d.ts +350 -0
- package/dist/collab/handoff.js +513 -0
- package/dist/collab/index.d.ts +9 -0
- package/dist/collab/index.js +8 -0
- package/dist/collab/lifecycle.d.ts +449 -0
- package/dist/collab/lifecycle.js +534 -0
- package/dist/collab/messages.d.ts +148 -0
- package/dist/collab/messages.js +583 -0
- package/dist/collab/notify.d.ts +47 -0
- package/dist/collab/notify.js +140 -0
- package/dist/collab/preflight.d.ts +31 -0
- package/dist/collab/preflight.js +271 -0
- package/dist/collab/resources.d.ts +119 -0
- package/dist/collab/resources.js +451 -0
- package/dist/collab/store.d.ts +143 -0
- package/dist/collab/store.js +606 -0
- package/dist/collab/types.d.ts +354 -0
- package/dist/collab/types.js +1 -0
- package/dist/collab/validation.d.ts +10 -0
- package/dist/collab/validation.js +303 -0
- package/dist/continuity/context.d.ts +24 -0
- package/dist/continuity/context.js +179 -0
- package/dist/continuity/index.d.ts +3 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.d.ts +37 -0
- package/dist/continuity/paths.js +258 -0
- package/dist/continuity/service.d.ts +59 -0
- package/dist/continuity/service.js +1258 -0
- package/dist/hosts/claude/native.d.ts +79 -0
- package/dist/hosts/claude/native.js +853 -0
- package/dist/hosts/codex/native.d.ts +345 -0
- package/dist/hosts/codex/native.js +1528 -0
- package/dist/hosts/codex/protocol.d.ts +51 -0
- package/dist/hosts/codex/protocol.js +42 -0
- package/dist/hosts/detect.d.ts +28 -0
- package/dist/hosts/detect.js +73 -0
- package/dist/hosts/index.d.ts +10 -0
- package/dist/hosts/index.js +10 -0
- package/dist/hosts/integration/bundle.d.ts +16 -0
- package/dist/hosts/integration/bundle.js +81 -0
- package/dist/hosts/integration/hook-entry.d.ts +1 -0
- package/dist/hosts/integration/hook-entry.js +68 -0
- package/dist/hosts/integration/hook.d.ts +32 -0
- package/dist/hosts/integration/hook.js +781 -0
- package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
- package/dist/hosts/integration/pi-extension-runtime.js +89 -0
- package/dist/hosts/integration/pi-template.d.ts +3 -0
- package/dist/hosts/integration/pi-template.js +5 -0
- package/dist/hosts/integration/service.d.ts +41 -0
- package/dist/hosts/integration/service.js +1381 -0
- package/dist/hosts/integration/support.d.ts +36 -0
- package/dist/hosts/integration/support.js +41 -0
- package/dist/hosts/integration/templates.d.ts +22 -0
- package/dist/hosts/integration/templates.js +70 -0
- package/dist/hosts/pi/protocol.d.ts +22 -0
- package/dist/hosts/pi/protocol.js +28 -0
- package/dist/hosts/pi/query-result.d.ts +22 -0
- package/dist/hosts/pi/query-result.js +710 -0
- package/dist/hosts/pi/rpc.d.ts +937 -0
- package/dist/hosts/pi/rpc.js +1621 -0
- package/dist/hosts/pilot.d.ts +485 -0
- package/dist/hosts/pilot.js +593 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/infra/fs.d.ts +27 -0
- package/dist/infra/fs.js +130 -0
- package/dist/infra/git.d.ts +60 -0
- package/dist/infra/git.js +358 -0
- package/dist/infra/owned-process-group.d.ts +173 -0
- package/dist/infra/owned-process-group.js +571 -0
- package/dist/install/apply.d.ts +65 -0
- package/dist/install/apply.js +663 -0
- package/dist/install/index.d.ts +5 -0
- package/dist/install/index.js +5 -0
- package/dist/install/ownership.d.ts +85 -0
- package/dist/install/ownership.js +430 -0
- package/dist/install/plan.d.ts +39 -0
- package/dist/install/plan.js +97 -0
- package/dist/install/rollback.d.ts +18 -0
- package/dist/install/rollback.js +544 -0
- package/dist/install/runtime.d.ts +76 -0
- package/dist/install/runtime.js +1145 -0
- package/dist/knowledge/catalog.d.ts +47 -0
- package/dist/knowledge/catalog.js +308 -0
- package/dist/knowledge/context.d.ts +28 -0
- package/dist/knowledge/context.js +61 -0
- package/dist/knowledge/index.d.ts +3 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/journal.d.ts +16 -0
- package/dist/knowledge/journal.js +109 -0
- package/dist/knowledge/lifecycle.d.ts +8 -0
- package/dist/knowledge/lifecycle.js +1001 -0
- package/dist/knowledge/paths.d.ts +37 -0
- package/dist/knowledge/paths.js +236 -0
- package/dist/knowledge/refs.d.ts +36 -0
- package/dist/knowledge/refs.js +267 -0
- package/dist/knowledge/service.d.ts +21 -0
- package/dist/knowledge/service.js +393 -0
- package/dist/knowledge/types.d.ts +171 -0
- package/dist/knowledge/types.js +1 -0
- package/dist/knowledge/write.d.ts +8 -0
- package/dist/knowledge/write.js +1223 -0
- package/dist/package-info.d.ts +6 -0
- package/dist/package-info.js +19 -0
- package/dist/schemas/code.d.ts +377 -0
- package/dist/schemas/code.js +145 -0
- package/dist/schemas/collaboration.d.ts +115 -0
- package/dist/schemas/collaboration.js +106 -0
- package/dist/schemas/continuity.d.ts +315 -0
- package/dist/schemas/continuity.js +199 -0
- package/dist/schemas/diagnostics.d.ts +28 -0
- package/dist/schemas/diagnostics.js +27 -0
- package/dist/schemas/doctor.d.ts +157 -0
- package/dist/schemas/doctor.js +75 -0
- package/dist/schemas/document.d.ts +644 -0
- package/dist/schemas/document.js +296 -0
- package/dist/schemas/errors.d.ts +177 -0
- package/dist/schemas/errors.js +176 -0
- package/dist/schemas/evidence.d.ts +24 -0
- package/dist/schemas/evidence.js +16 -0
- package/dist/schemas/host.d.ts +436 -0
- package/dist/schemas/host.js +201 -0
- package/dist/schemas/index.d.ts +15 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/init.d.ts +121 -0
- package/dist/schemas/init.js +94 -0
- package/dist/schemas/install.d.ts +213 -0
- package/dist/schemas/install.js +139 -0
- package/dist/schemas/machine.d.ts +43 -0
- package/dist/schemas/machine.js +96 -0
- package/dist/schemas/outcome.d.ts +23 -0
- package/dist/schemas/outcome.js +17 -0
- package/dist/schemas/paths.d.ts +19 -0
- package/dist/schemas/paths.js +63 -0
- package/dist/schemas/workspace.d.ts +120 -0
- package/dist/schemas/workspace.js +201 -0
- package/dist/workspace/config-file.d.ts +36 -0
- package/dist/workspace/config-file.js +237 -0
- package/dist/workspace/config.d.ts +28 -0
- package/dist/workspace/config.js +79 -0
- package/dist/workspace/discover.d.ts +34 -0
- package/dist/workspace/discover.js +195 -0
- package/dist/workspace/documents.d.ts +21 -0
- package/dist/workspace/documents.js +395 -0
- package/dist/workspace/ignore.d.ts +10 -0
- package/dist/workspace/ignore.js +48 -0
- package/dist/workspace/index.d.ts +7 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/repositories.d.ts +24 -0
- package/dist/workspace/repositories.js +411 -0
- package/dist/workspace/resolve.d.ts +24 -0
- package/dist/workspace/resolve.js +268 -0
- package/dist/workspace/types.d.ts +71 -0
- package/dist/workspace/types.js +1 -0
- package/dist/workspace/validation.d.ts +18 -0
- package/dist/workspace/validation.js +197 -0
- package/package.json +105 -4
- package/skills/pappra/SKILL.md +68 -0
- package/skills/pappra/references/hosts.md +30 -0
- package/skills/pappra/references/lifecycle.md +37 -0
- package/skills/pappra/references/operations.md +14 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
import { errorCode, object, parseJson } from '../../collab/validation.js';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { buildMessage } from '../../collab/envelope.js';
|
|
7
|
+
import { submitMessage } from '../../collab/messages.js';
|
|
8
|
+
import { readRecord } from '../../collab/store.js';
|
|
9
|
+
// Native background-session query/cancel for Claude Code 2.1.292 as installed.
|
|
10
|
+
//
|
|
11
|
+
// Observed installed shape (evidence: claude-background-agents.json,
|
|
12
|
+
// claude-raw-stop-proof.json, task completion-20261016-a9f2):
|
|
13
|
+
// active row: {pid, id, cwd, kind:'background', startedAt, sessionId, name,
|
|
14
|
+
// status:'idle'|'busy', state:'blocked'|'working'}
|
|
15
|
+
// terminal row: {id, cwd, kind:'background', startedAt, sessionId, name, state:'stopped'|'done'}
|
|
16
|
+
//
|
|
17
|
+
// Terminal shape observed on 2.1.292 after a real model reply plus native stop: `claude agents
|
|
18
|
+
// --json --all --cwd` returns `state:'done'` (not `stopped`) with the same id/sessionId/cwd/kind
|
|
19
|
+
// and no pid/status. `done` is a terminal lifecycle observation only; it is never a completed
|
|
20
|
+
// business result (evidence/claude-model-recovery.json).
|
|
21
|
+
//
|
|
22
|
+
// * `sessionId` is the canonical native UUID; `id` (8 hex) is the CLI command key.
|
|
23
|
+
// `claude logs <UUID>` exits 1 while `claude logs <id>` succeeds, so logs/stop always use
|
|
24
|
+
// the exact associated command key, and the UUID is matched exactly (never a name/prefix).
|
|
25
|
+
// * `--bg` ignores `--session-id` and picks its own UUID, so a requested UUID is never
|
|
26
|
+
// treated as the observed one.
|
|
27
|
+
// * `status:'idle'` is a live background process, not terminal task completion.
|
|
28
|
+
// * The post-stop row keeps id/sessionId/cwd/kind with `state:'stopped'` and exposes no
|
|
29
|
+
// pid/status; its `startedAt` is a service start time, not the process start time, so it is
|
|
30
|
+
// never compared against the active row's `startedAt`. Absence of a terminal row is
|
|
31
|
+
// `unknown`, not proof.
|
|
32
|
+
//
|
|
33
|
+
// It is deliberately NOT a print-session takeover: it never prompts, resumes, removes,
|
|
34
|
+
// respawns or attaches. It never uses the shared record as native status.
|
|
35
|
+
const SCHEMA = 'harness-collab/claude-native-session/1';
|
|
36
|
+
const MAX_TIMEOUT_MS = 30000;
|
|
37
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
38
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
39
|
+
const DEFAULT_POLL_MS = 500;
|
|
40
|
+
const DEFAULT_STOP_TIMEOUT_MS = 15000;
|
|
41
|
+
const DEFAULT_STREAM_GRACE_MS = 500;
|
|
42
|
+
const ACCEPTED_HARNESSES = new Set(['claude', 'claude-code']);
|
|
43
|
+
const EXPECTED_RUNNER = 'claude-background-cli';
|
|
44
|
+
const COMMAND_KEY = /^[0-9a-f]{8}$/;
|
|
45
|
+
const ACTIVE_STATUS = new Set(['idle', 'busy', 'running', 'active', 'working']);
|
|
46
|
+
const ACTIVE_STATE = new Set(['blocked', 'working']);
|
|
47
|
+
// `done` is the observed post-stop shape after a real model reply (2.1.292). It is terminal for
|
|
48
|
+
// lifecycle purposes only and is never mapped to a completed task result.
|
|
49
|
+
const TERMINAL_STATE = new Set([
|
|
50
|
+
'done',
|
|
51
|
+
'stopped',
|
|
52
|
+
'exited',
|
|
53
|
+
'dead',
|
|
54
|
+
'ended',
|
|
55
|
+
'finished',
|
|
56
|
+
'completed',
|
|
57
|
+
'cancelled',
|
|
58
|
+
]);
|
|
59
|
+
export class ClaudeNativeRefusal extends Error {
|
|
60
|
+
status;
|
|
61
|
+
code;
|
|
62
|
+
detail;
|
|
63
|
+
constructor(status, code, detail = null) {
|
|
64
|
+
super(code);
|
|
65
|
+
this.status = status;
|
|
66
|
+
this.code = code;
|
|
67
|
+
this.detail = detail;
|
|
68
|
+
this.status = status;
|
|
69
|
+
this.code = code;
|
|
70
|
+
this.detail = detail;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function refuse(code, status = 'invalid', detail = null) {
|
|
74
|
+
throw new ClaudeNativeRefusal(status, code, detail);
|
|
75
|
+
}
|
|
76
|
+
const sha256 = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
77
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
const nowIso = () => new Date().toISOString();
|
|
79
|
+
const realpath = (file) => {
|
|
80
|
+
try {
|
|
81
|
+
return fs.realpathSync(file);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const resolvePath = (file) => realpath(file) ?? path.resolve(file);
|
|
88
|
+
function absolute(value, code) {
|
|
89
|
+
if (typeof value !== 'string' ||
|
|
90
|
+
!path.isAbsolute(value) ||
|
|
91
|
+
value.includes('\0'))
|
|
92
|
+
refuse(code);
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
function bounded(value, fallback, code, cap = MAX_TIMEOUT_MS) {
|
|
96
|
+
const chosen = value ?? fallback;
|
|
97
|
+
if (!Number.isSafeInteger(chosen) || chosen < 1 || chosen > cap)
|
|
98
|
+
refuse(code);
|
|
99
|
+
return chosen;
|
|
100
|
+
}
|
|
101
|
+
function normalize(options) {
|
|
102
|
+
const timeoutMs = bounded(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeout_out_of_range');
|
|
103
|
+
if (!Number.isSafeInteger(options.attempt) || options.attempt < 1)
|
|
104
|
+
refuse('invalid_attempt');
|
|
105
|
+
if (!Number.isSafeInteger(options.ownerEpoch) || options.ownerEpoch < 1)
|
|
106
|
+
refuse('invalid_owner_epoch');
|
|
107
|
+
for (const key of ['taskRoot', 'agentId', 'sessionId']) {
|
|
108
|
+
if (typeof options[key] !== 'string' || !options[key].trim())
|
|
109
|
+
refuse(`${key}_required`);
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
schema: SCHEMA,
|
|
113
|
+
claudeBin: typeof options.claudeBin === 'string' && options.claudeBin
|
|
114
|
+
? options.claudeBin
|
|
115
|
+
: 'claude',
|
|
116
|
+
env: options.env ?? undefined,
|
|
117
|
+
taskRoot: absolute(options.taskRoot, 'task_root_must_be_absolute'),
|
|
118
|
+
subtaskId: typeof options.subtaskId === 'string' && options.subtaskId.trim()
|
|
119
|
+
? options.subtaskId
|
|
120
|
+
: refuse('subtask_required'),
|
|
121
|
+
attempt: options.attempt,
|
|
122
|
+
ownerEpoch: options.ownerEpoch,
|
|
123
|
+
agentId: options.agentId,
|
|
124
|
+
sessionId: options.sessionId,
|
|
125
|
+
nativeSessionId: options.nativeSessionId ?? null,
|
|
126
|
+
cwd: absolute(options.cwd, 'cwd_must_be_absolute'),
|
|
127
|
+
expectedPid: options.expectedPid ?? null,
|
|
128
|
+
expectedStartedAt: options.expectedStartedAt ?? null,
|
|
129
|
+
evidenceDir: options.evidenceDir === undefined || options.evidenceDir === null
|
|
130
|
+
? null
|
|
131
|
+
: absolute(options.evidenceDir, 'evidence_dir_must_be_absolute'),
|
|
132
|
+
timeoutMs,
|
|
133
|
+
pollMs: bounded(options.pollMs, DEFAULT_POLL_MS, 'poll_ms_out_of_range'),
|
|
134
|
+
stopTimeoutMs: bounded(options.stopTimeoutMs, DEFAULT_STOP_TIMEOUT_MS, 'stop_timeout_out_of_range'),
|
|
135
|
+
maxBytes: bounded(options.maxBytes, MAX_BYTES, 'max_bytes_out_of_range', MAX_BYTES),
|
|
136
|
+
streamGraceMs: bounded(options.streamGraceMs, DEFAULT_STREAM_GRACE_MS, 'stream_grace_out_of_range'),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Bounded local subprocess capture. Parent exit is separate from stream close: after the parent
|
|
141
|
+
* exits, only this module's own pipe handles are detached after a bounded grace, so a descendant
|
|
142
|
+
* that inherited stdout cannot hold the promise open past its deadline. No descendant is
|
|
143
|
+
* signalled. `streams_detached: true` means the output may be incomplete.
|
|
144
|
+
*/
|
|
145
|
+
export function runBounded(bin, args, options = {}) {
|
|
146
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
147
|
+
const maxBytes = options.maxBytes ?? MAX_BYTES;
|
|
148
|
+
const streamGraceMs = options.streamGraceMs ?? DEFAULT_STREAM_GRACE_MS;
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const settle = (payload) => resolve(payload);
|
|
151
|
+
let child;
|
|
152
|
+
try {
|
|
153
|
+
child = spawn(bin, args, {
|
|
154
|
+
cwd: options.cwd,
|
|
155
|
+
env: options.env,
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
settle({
|
|
161
|
+
exit_code: null,
|
|
162
|
+
signal: null,
|
|
163
|
+
parent_exited: false,
|
|
164
|
+
stream_closed: false,
|
|
165
|
+
streams_detached: false,
|
|
166
|
+
timed_out: false,
|
|
167
|
+
killed_after_eof_timeout: false,
|
|
168
|
+
spawn_failed: true,
|
|
169
|
+
spawn_error: error instanceof Error ? error.message : String(error),
|
|
170
|
+
truncated: false,
|
|
171
|
+
stdout: Buffer.alloc(0),
|
|
172
|
+
stderr: Buffer.alloc(0),
|
|
173
|
+
});
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const out = [];
|
|
177
|
+
const err = [];
|
|
178
|
+
let bytes = 0;
|
|
179
|
+
let truncated = false;
|
|
180
|
+
let timedOut = false;
|
|
181
|
+
let killed = false;
|
|
182
|
+
let spawnError = null;
|
|
183
|
+
let parentExited = false;
|
|
184
|
+
let streamClosed = false;
|
|
185
|
+
let streamsDetached = false;
|
|
186
|
+
let exitCode = null;
|
|
187
|
+
let signal = null;
|
|
188
|
+
let done = false;
|
|
189
|
+
let timer;
|
|
190
|
+
let grace;
|
|
191
|
+
const finish = () => {
|
|
192
|
+
if (done)
|
|
193
|
+
return;
|
|
194
|
+
done = true;
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
clearTimeout(grace);
|
|
197
|
+
settle({
|
|
198
|
+
exit_code: exitCode,
|
|
199
|
+
signal,
|
|
200
|
+
parent_exited: parentExited,
|
|
201
|
+
stream_closed: streamClosed,
|
|
202
|
+
streams_detached: streamsDetached,
|
|
203
|
+
timed_out: timedOut,
|
|
204
|
+
killed_after_eof_timeout: killed,
|
|
205
|
+
spawn_failed: spawnError !== null,
|
|
206
|
+
spawn_error: spawnError?.message ?? null,
|
|
207
|
+
truncated,
|
|
208
|
+
stdout: Buffer.concat(out),
|
|
209
|
+
stderr: Buffer.concat(err),
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
const collect = (list) => (chunk) => {
|
|
213
|
+
const room = Math.max(0, maxBytes - bytes);
|
|
214
|
+
if (room > 0)
|
|
215
|
+
list.push(chunk.subarray(0, room));
|
|
216
|
+
bytes += Math.min(room, chunk.length);
|
|
217
|
+
if (chunk.length > room)
|
|
218
|
+
truncated = true;
|
|
219
|
+
};
|
|
220
|
+
for (const [stream, list] of [
|
|
221
|
+
[child.stdout, out],
|
|
222
|
+
[child.stderr, err],
|
|
223
|
+
]) {
|
|
224
|
+
stream?.on('data', collect(list));
|
|
225
|
+
stream?.on('error', () => {
|
|
226
|
+
/* drained; the exit path reports the outcome */
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
child.stdin?.on('error', () => {
|
|
230
|
+
/* drained */
|
|
231
|
+
});
|
|
232
|
+
child.on('error', (error) => {
|
|
233
|
+
spawnError ??= error;
|
|
234
|
+
});
|
|
235
|
+
child.on('exit', (code, sig) => {
|
|
236
|
+
parentExited = true;
|
|
237
|
+
exitCode = code;
|
|
238
|
+
signal = sig;
|
|
239
|
+
grace = setTimeout(() => {
|
|
240
|
+
if (streamClosed)
|
|
241
|
+
return;
|
|
242
|
+
streamsDetached = true;
|
|
243
|
+
for (const stream of [child.stdin, child.stdout, child.stderr]) {
|
|
244
|
+
try {
|
|
245
|
+
stream?.destroy();
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
/* already gone */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
setTimeout(finish, 25);
|
|
252
|
+
}, streamGraceMs);
|
|
253
|
+
});
|
|
254
|
+
child.on('close', (code, sig) => {
|
|
255
|
+
parentExited = true;
|
|
256
|
+
if (exitCode === null) {
|
|
257
|
+
exitCode = code;
|
|
258
|
+
signal = sig;
|
|
259
|
+
}
|
|
260
|
+
streamClosed = true;
|
|
261
|
+
clearTimeout(grace);
|
|
262
|
+
finish();
|
|
263
|
+
});
|
|
264
|
+
timer = setTimeout(() => {
|
|
265
|
+
timedOut = true;
|
|
266
|
+
killed = true;
|
|
267
|
+
try {
|
|
268
|
+
child.kill('SIGKILL');
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
/* already gone */
|
|
272
|
+
}
|
|
273
|
+
}, timeoutMs);
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
/** The record supplies the expected identity only; it never supplies queried execution facts. */
|
|
277
|
+
function requireRecordTarget(opts) {
|
|
278
|
+
const record = readRecord(opts.taskRoot);
|
|
279
|
+
if (!record)
|
|
280
|
+
refuse('task_record_unavailable', 'needs_input');
|
|
281
|
+
if (record.coordinator?.owner_epoch !== opts.ownerEpoch)
|
|
282
|
+
refuse('stale_epoch', 'needs_input');
|
|
283
|
+
const target = (record.attempts ?? []).find((item) => item.subtask_id === opts.subtaskId && item.attempt === opts.attempt);
|
|
284
|
+
if (!target)
|
|
285
|
+
refuse('unknown_attempt', 'needs_input');
|
|
286
|
+
const assignee = target.assignee ?? {};
|
|
287
|
+
if (assignee.agent_id !== opts.agentId ||
|
|
288
|
+
assignee.session_id !== opts.sessionId)
|
|
289
|
+
refuse('wrong_assignee', 'needs_input');
|
|
290
|
+
if (!ACCEPTED_HARNESSES.has(assignee.harness ?? ''))
|
|
291
|
+
refuse('native_harness_mismatch', 'needs_input', `assignee harness ${assignee.harness ?? 'unknown'}`);
|
|
292
|
+
const native = target.native ?? {};
|
|
293
|
+
if (native.runner !== EXPECTED_RUNNER)
|
|
294
|
+
refuse('native_runner_mismatch', 'needs_input', `native runner ${native.runner ?? 'unknown'}`);
|
|
295
|
+
const nativeSessionId = native.session_id ?? null;
|
|
296
|
+
const commandKey = native.run_id ?? null;
|
|
297
|
+
if (nativeSessionId === null)
|
|
298
|
+
refuse('native_session_unrecorded', 'needs_input');
|
|
299
|
+
if (opts.nativeSessionId !== null && opts.nativeSessionId !== nativeSessionId)
|
|
300
|
+
refuse('native_session_ref_mismatch', 'needs_input');
|
|
301
|
+
if (typeof commandKey !== 'string' || !COMMAND_KEY.test(commandKey))
|
|
302
|
+
refuse('native_command_key_unrecorded', 'needs_input');
|
|
303
|
+
const recordedCwd = target.cwd ?? assignee.cwd ?? null;
|
|
304
|
+
if (recordedCwd !== null &&
|
|
305
|
+
resolvePath(recordedCwd) !== resolvePath(opts.cwd))
|
|
306
|
+
refuse('native_cwd_mismatch', 'needs_input');
|
|
307
|
+
opts.record = record;
|
|
308
|
+
opts.assigneeAgent = assignee.agent_id;
|
|
309
|
+
opts.assigneeSession = assignee.session_id;
|
|
310
|
+
opts.assigneeHarness = assignee.harness;
|
|
311
|
+
return { record, target, nativeSessionId, commandKey };
|
|
312
|
+
}
|
|
313
|
+
async function listSessions(opts) {
|
|
314
|
+
const result = await runBounded(opts.claudeBin, ['agents', '--json', '--all', '--cwd', opts.cwd], {
|
|
315
|
+
cwd: opts.cwd,
|
|
316
|
+
env: opts.env,
|
|
317
|
+
timeoutMs: opts.timeoutMs,
|
|
318
|
+
maxBytes: opts.maxBytes,
|
|
319
|
+
streamGraceMs: opts.streamGraceMs,
|
|
320
|
+
});
|
|
321
|
+
if (result.spawn_failed)
|
|
322
|
+
refuse('spawn_failed', 'needs_input', result.spawn_error);
|
|
323
|
+
if (result.timed_out)
|
|
324
|
+
refuse('native_discovery_timeout', 'needs_input');
|
|
325
|
+
if (!result.parent_exited)
|
|
326
|
+
refuse('native_discovery_parent_not_exited', 'needs_input');
|
|
327
|
+
if (result.streams_detached)
|
|
328
|
+
refuse('native_discovery_stream_unproven', 'needs_input');
|
|
329
|
+
if (result.exit_code !== 0)
|
|
330
|
+
refuse('native_discovery_failed', 'needs_input', `agents exited ${result.exit_code}`);
|
|
331
|
+
if (result.truncated)
|
|
332
|
+
refuse('native_discovery_truncated', 'needs_input');
|
|
333
|
+
const text = result.stdout.toString('utf8').trim();
|
|
334
|
+
let parsed;
|
|
335
|
+
try {
|
|
336
|
+
parsed = parseJson(text);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
refuse('native_agents_json_invalid', 'needs_input');
|
|
340
|
+
}
|
|
341
|
+
if (!Array.isArray(parsed))
|
|
342
|
+
refuse('native_agents_json_invalid', 'needs_input');
|
|
343
|
+
if (!parsed.every(object))
|
|
344
|
+
refuse('native_agents_json_invalid', 'needs_input');
|
|
345
|
+
return { rows: parsed, exit_code: result.exit_code, captured_at: nowIso() };
|
|
346
|
+
}
|
|
347
|
+
function classifyRow(row) {
|
|
348
|
+
if (typeof row.state === 'string' && TERMINAL_STATE.has(row.state))
|
|
349
|
+
return {
|
|
350
|
+
observation: 'terminal',
|
|
351
|
+
native_state: row.state,
|
|
352
|
+
native_status: typeof row.status === 'string' ? row.status : null,
|
|
353
|
+
};
|
|
354
|
+
if (typeof row.status === 'string' && ACTIVE_STATUS.has(row.status))
|
|
355
|
+
return {
|
|
356
|
+
observation: 'active',
|
|
357
|
+
native_state: typeof row.state === 'string' ? row.state : null,
|
|
358
|
+
native_status: row.status,
|
|
359
|
+
};
|
|
360
|
+
if (typeof row.state === 'string' && ACTIVE_STATE.has(row.state))
|
|
361
|
+
return {
|
|
362
|
+
observation: 'active',
|
|
363
|
+
native_state: row.state,
|
|
364
|
+
native_status: typeof row.status === 'string' ? row.status : null,
|
|
365
|
+
};
|
|
366
|
+
refuse('native_status_unsupported', 'needs_input', `status=${row.status ?? 'null'} state=${row.state ?? 'null'}`);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Exact canonical UUID + exact cwd + exact CLI command key + background kind. Pins (pid and the
|
|
370
|
+
* active-row startedAt) are only applied to a live row, because the post-stop row deliberately
|
|
371
|
+
* has no pid/status and its startedAt is a different thing entirely.
|
|
372
|
+
*/
|
|
373
|
+
function matchRow(rows, { nativeSessionId, commandKey, cwd, expectedPid, expectedStartedAt, }) {
|
|
374
|
+
const matches = rows.filter((row) => row.sessionId === nativeSessionId);
|
|
375
|
+
if (matches.length === 0)
|
|
376
|
+
refuse('native_session_absent', 'needs_input');
|
|
377
|
+
if (matches.length > 1)
|
|
378
|
+
refuse('native_session_ambiguous', 'needs_input');
|
|
379
|
+
const row = matches[0];
|
|
380
|
+
if (row.kind !== 'background')
|
|
381
|
+
refuse('native_kind_unsupported', 'needs_input', String(row.kind ?? 'unknown'));
|
|
382
|
+
if (row.id !== commandKey)
|
|
383
|
+
refuse('native_command_key_mismatch', 'needs_input', `row id ${row.id ?? 'unknown'}`);
|
|
384
|
+
if (typeof row.cwd !== 'string' || resolvePath(row.cwd) !== resolvePath(cwd))
|
|
385
|
+
refuse('native_cwd_mismatch', 'needs_input');
|
|
386
|
+
const classified = classifyRow(row);
|
|
387
|
+
if (classified.observation === 'active') {
|
|
388
|
+
if (expectedPid !== null &&
|
|
389
|
+
row.pid !== undefined &&
|
|
390
|
+
row.pid !== expectedPid)
|
|
391
|
+
refuse('native_pid_mismatch', 'needs_input');
|
|
392
|
+
if (expectedPid !== null && row.pid === undefined)
|
|
393
|
+
refuse('native_pid_unsupported', 'needs_input');
|
|
394
|
+
if (expectedStartedAt !== null && row.startedAt !== expectedStartedAt)
|
|
395
|
+
refuse('native_started_at_mismatch', 'needs_input');
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
row,
|
|
399
|
+
observation: classified.observation,
|
|
400
|
+
native_state: classified.native_state,
|
|
401
|
+
native_status: classified.native_status,
|
|
402
|
+
command_key: row.id,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
function tryWritePrivate(file, bytes) {
|
|
406
|
+
try {
|
|
407
|
+
fs.writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 });
|
|
408
|
+
return { ref: file, digest: sha256(bytes), bytes: bytes.length };
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
return {
|
|
412
|
+
error: errorCode(error) === 'EEXIST'
|
|
413
|
+
? 'evidence_exists'
|
|
414
|
+
: 'evidence_write_failed',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/** Bounded exact `logs <command-key>` read; raw output is only persisted privately. */
|
|
419
|
+
async function readLogs(opts, commandKey) {
|
|
420
|
+
const result = await runBounded(opts.claudeBin, ['logs', commandKey], {
|
|
421
|
+
cwd: opts.cwd,
|
|
422
|
+
env: opts.env,
|
|
423
|
+
timeoutMs: opts.timeoutMs,
|
|
424
|
+
maxBytes: opts.maxBytes,
|
|
425
|
+
streamGraceMs: opts.streamGraceMs,
|
|
426
|
+
});
|
|
427
|
+
if (result.spawn_failed)
|
|
428
|
+
refuse('spawn_failed', 'needs_input', result.spawn_error);
|
|
429
|
+
if (result.timed_out)
|
|
430
|
+
refuse('native_logs_timeout', 'needs_input');
|
|
431
|
+
const body = result.stdout;
|
|
432
|
+
const evidence = {
|
|
433
|
+
command_key: commandKey,
|
|
434
|
+
exit_code: result.exit_code,
|
|
435
|
+
parent_exited: result.parent_exited,
|
|
436
|
+
stream_closed: result.stream_closed,
|
|
437
|
+
streams_detached: result.streams_detached,
|
|
438
|
+
timed_out: result.timed_out,
|
|
439
|
+
truncated: result.truncated,
|
|
440
|
+
lines: body
|
|
441
|
+
.toString('utf8')
|
|
442
|
+
.split('\n')
|
|
443
|
+
.filter((line) => line.length > 0).length,
|
|
444
|
+
bytes: body.length,
|
|
445
|
+
digest: sha256(body),
|
|
446
|
+
// Native terminal output commonly carries ANSI control sequences; the raw bytes are kept
|
|
447
|
+
// private and never interpreted into a model receipt.
|
|
448
|
+
ansi_sequences_present: body.includes(0x1b),
|
|
449
|
+
captured_at: nowIso(),
|
|
450
|
+
};
|
|
451
|
+
if (opts.evidenceDir) {
|
|
452
|
+
try {
|
|
453
|
+
fs.mkdirSync(opts.evidenceDir, { recursive: true, mode: 0o700 });
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
/* reported below */
|
|
457
|
+
}
|
|
458
|
+
const written = tryWritePrivate(path.join(opts.evidenceDir, 'claude-native-logs.txt'), body);
|
|
459
|
+
if (written.error)
|
|
460
|
+
evidence.evidence_error = written.error;
|
|
461
|
+
else
|
|
462
|
+
evidence.raw_evidence = written;
|
|
463
|
+
}
|
|
464
|
+
return evidence;
|
|
465
|
+
}
|
|
466
|
+
function ids(opts) {
|
|
467
|
+
if (!opts.record?.coordinator || !opts.assigneeAgent || !opts.assigneeSession)
|
|
468
|
+
refuse('protocol_identity_unavailable');
|
|
469
|
+
const coordinator = opts.record.coordinator;
|
|
470
|
+
return {
|
|
471
|
+
taskId: opts.record.task_id,
|
|
472
|
+
from: {
|
|
473
|
+
agent_id: coordinator.agent_id,
|
|
474
|
+
session_id: coordinator.session_id,
|
|
475
|
+
harness: coordinator.harness ?? 'pi',
|
|
476
|
+
},
|
|
477
|
+
to: {
|
|
478
|
+
agent_id: opts.assigneeAgent,
|
|
479
|
+
session_id: opts.assigneeSession,
|
|
480
|
+
harness: opts.assigneeHarness ?? 'claude',
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/** Registers the query intent before any native read; returns the message id. */
|
|
485
|
+
function registerStatusQuery(opts) {
|
|
486
|
+
const { taskId, from, to } = ids(opts);
|
|
487
|
+
const query = submitMessage(opts.taskRoot, buildMessage({
|
|
488
|
+
type: 'STATUS_QUERY',
|
|
489
|
+
task_id: taskId,
|
|
490
|
+
from,
|
|
491
|
+
to,
|
|
492
|
+
owner_epoch: opts.ownerEpoch,
|
|
493
|
+
body: { reason: 'native background-session observation' },
|
|
494
|
+
}));
|
|
495
|
+
if (query.ok !== true)
|
|
496
|
+
refuse('status_query_not_recorded', 'invalid', query.reason ?? null);
|
|
497
|
+
return query.registration.message_id;
|
|
498
|
+
}
|
|
499
|
+
/** Reports the factual native observation; never maps a terminal session to task completion. */
|
|
500
|
+
function reportStatus(opts, queryId, observed) {
|
|
501
|
+
const { taskId, from, to } = ids(opts);
|
|
502
|
+
const status = submitMessage(opts.taskRoot, buildMessage({
|
|
503
|
+
type: 'STATUS',
|
|
504
|
+
task_id: taskId,
|
|
505
|
+
from: to,
|
|
506
|
+
to: from,
|
|
507
|
+
owner_epoch: opts.ownerEpoch,
|
|
508
|
+
reply_to: queryId,
|
|
509
|
+
subtask_id: opts.subtaskId,
|
|
510
|
+
attempt: opts.attempt,
|
|
511
|
+
body: {
|
|
512
|
+
status: observed.observation === 'active' ? 'running' : 'unknown',
|
|
513
|
+
native_session_id: observed.native_session_id,
|
|
514
|
+
native_command_key: observed.command_key,
|
|
515
|
+
native_state: observed.native_state,
|
|
516
|
+
native_status: observed.native_status,
|
|
517
|
+
native_cwd: observed.cwd,
|
|
518
|
+
observation: observed.observation,
|
|
519
|
+
evidence_ref: typeof observed.evidence_ref === 'string'
|
|
520
|
+
? observed.evidence_ref
|
|
521
|
+
: null,
|
|
522
|
+
},
|
|
523
|
+
}));
|
|
524
|
+
if (status.ok !== true)
|
|
525
|
+
refuse('status_not_recorded', 'invalid', status.reason ?? null);
|
|
526
|
+
return status.registration.message_id;
|
|
527
|
+
}
|
|
528
|
+
function nativePayload(opts, nativeSessionId, matched, extra) {
|
|
529
|
+
return {
|
|
530
|
+
schema: SCHEMA,
|
|
531
|
+
task_id: opts.taskId ?? undefined,
|
|
532
|
+
subtask_id: opts.subtaskId,
|
|
533
|
+
attempt: opts.attempt,
|
|
534
|
+
owner_epoch: opts.ownerEpoch,
|
|
535
|
+
native: {
|
|
536
|
+
session_id: nativeSessionId,
|
|
537
|
+
command_key: matched.command_key,
|
|
538
|
+
cwd: matched.row.cwd,
|
|
539
|
+
kind: matched.row.kind,
|
|
540
|
+
status: matched.row.status ?? null,
|
|
541
|
+
state: matched.row.state ?? null,
|
|
542
|
+
pid: Number.isSafeInteger(matched.row.pid) ? matched.row.pid : null,
|
|
543
|
+
started_at: Number.isSafeInteger(matched.row.startedAt)
|
|
544
|
+
? matched.row.startedAt
|
|
545
|
+
: null,
|
|
546
|
+
},
|
|
547
|
+
...extra,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
function refusalResult(schema, error, extra = {}) {
|
|
551
|
+
const known = error instanceof ClaudeNativeRefusal;
|
|
552
|
+
return {
|
|
553
|
+
schema,
|
|
554
|
+
status: known ? error.status : 'invalid',
|
|
555
|
+
blockers: [known ? error.code : 'internal_error'],
|
|
556
|
+
detail: known
|
|
557
|
+
? error.detail
|
|
558
|
+
: String(error instanceof Error ? error.message : error),
|
|
559
|
+
...extra,
|
|
560
|
+
claims_retained: true,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Read-only observation of one exact recorded background session. Registers the intent first,
|
|
565
|
+
* then reads native state; a missing/unknown row is `unknown`, never `stopped`.
|
|
566
|
+
*/
|
|
567
|
+
export async function queryClaudeSession(options) {
|
|
568
|
+
const schema = SCHEMA;
|
|
569
|
+
let statusQueryId = null;
|
|
570
|
+
try {
|
|
571
|
+
const opts = normalize(options);
|
|
572
|
+
const target = requireRecordTarget(opts);
|
|
573
|
+
opts.taskId = target.record.task_id ?? null;
|
|
574
|
+
opts.record = target.record;
|
|
575
|
+
opts.assigneeAgent = target.target.assignee?.agent_id ?? opts.agentId;
|
|
576
|
+
opts.assigneeSession = target.target.assignee?.session_id ?? opts.sessionId;
|
|
577
|
+
opts.assigneeHarness = target.target.assignee?.harness ?? 'claude';
|
|
578
|
+
statusQueryId = registerStatusQuery(opts);
|
|
579
|
+
const listed = await listSessions(opts);
|
|
580
|
+
const matched = matchRow(listed.rows, {
|
|
581
|
+
nativeSessionId: target.nativeSessionId,
|
|
582
|
+
commandKey: target.commandKey,
|
|
583
|
+
cwd: opts.cwd,
|
|
584
|
+
expectedPid: opts.expectedPid,
|
|
585
|
+
expectedStartedAt: opts.expectedStartedAt,
|
|
586
|
+
});
|
|
587
|
+
const logs = await readLogs(opts, target.commandKey);
|
|
588
|
+
const result = {
|
|
589
|
+
...nativePayload(opts, target.nativeSessionId, matched, {
|
|
590
|
+
status: matched.observation,
|
|
591
|
+
blockers: [],
|
|
592
|
+
observation: matched.observation,
|
|
593
|
+
task_result_claimed: false,
|
|
594
|
+
transport: {
|
|
595
|
+
kind: 'claude-cli-agents-json',
|
|
596
|
+
queried_at: listed.captured_at,
|
|
597
|
+
agents_exit_code: listed.exit_code,
|
|
598
|
+
session_count: listed.rows.length,
|
|
599
|
+
},
|
|
600
|
+
logs,
|
|
601
|
+
}),
|
|
602
|
+
evidence: {
|
|
603
|
+
request: ['agents --json --all --cwd', 'logs <command-key>'],
|
|
604
|
+
observed_ids: {
|
|
605
|
+
session_id: target.nativeSessionId,
|
|
606
|
+
command_key: target.commandKey,
|
|
607
|
+
},
|
|
608
|
+
queried_at: listed.captured_at,
|
|
609
|
+
},
|
|
610
|
+
reconciliation_required: false,
|
|
611
|
+
claims_retained: true,
|
|
612
|
+
};
|
|
613
|
+
try {
|
|
614
|
+
const statusId = reportStatus(opts, statusQueryId, {
|
|
615
|
+
observation: matched.observation,
|
|
616
|
+
native_session_id: target.nativeSessionId,
|
|
617
|
+
command_key: matched.command_key,
|
|
618
|
+
native_state: matched.native_state,
|
|
619
|
+
native_status: matched.native_status,
|
|
620
|
+
cwd: matched.row.cwd,
|
|
621
|
+
evidence_ref: object(logs.raw_evidence)
|
|
622
|
+
? (logs.raw_evidence['digest'] ?? logs.digest)
|
|
623
|
+
: logs.digest,
|
|
624
|
+
});
|
|
625
|
+
result.protocol = { status_query: statusQueryId, status: statusId };
|
|
626
|
+
}
|
|
627
|
+
catch (error) {
|
|
628
|
+
result.record_error =
|
|
629
|
+
error instanceof ClaudeNativeRefusal
|
|
630
|
+
? errorCode(error)
|
|
631
|
+
: 'internal_error';
|
|
632
|
+
result.protocol = { status_query: statusQueryId, status: null };
|
|
633
|
+
}
|
|
634
|
+
return result;
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
return refusalResult(schema, error, { status_query: statusQueryId });
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Request a stop of one exact recorded background session. A fresh active observation is
|
|
642
|
+
* required first, the CANCEL intent is recorded, and only the matching command key is stopped.
|
|
643
|
+
* The command's exit code is never the proof: the polled terminal native row is. Claims are
|
|
644
|
+
* retained, no CANCELLED is sent, and any failure after the recorded intent still returns
|
|
645
|
+
* `reconciliation_required: true` together with the known native observation.
|
|
646
|
+
*/
|
|
647
|
+
export async function cancelClaudeSession(options) {
|
|
648
|
+
const schema = SCHEMA;
|
|
649
|
+
const ctx = {
|
|
650
|
+
cancel_message_id: null,
|
|
651
|
+
intent_recorded: false,
|
|
652
|
+
intent_error: null,
|
|
653
|
+
native_command_key: null,
|
|
654
|
+
before: null,
|
|
655
|
+
stop_command: null,
|
|
656
|
+
last_observation: null,
|
|
657
|
+
evidence_error: null,
|
|
658
|
+
};
|
|
659
|
+
try {
|
|
660
|
+
const opts = normalize(options);
|
|
661
|
+
const target = requireRecordTarget(opts);
|
|
662
|
+
opts.taskId = target.record.task_id ?? null;
|
|
663
|
+
opts.record = target.record;
|
|
664
|
+
opts.assigneeAgent = target.target.assignee?.agent_id ?? opts.agentId;
|
|
665
|
+
opts.assigneeSession = target.target.assignee?.session_id ?? opts.sessionId;
|
|
666
|
+
opts.assigneeHarness = target.target.assignee?.harness ?? 'claude';
|
|
667
|
+
ctx.native_command_key = target.commandKey;
|
|
668
|
+
const before = await listSessions(opts);
|
|
669
|
+
const matched = matchRow(before.rows, {
|
|
670
|
+
nativeSessionId: target.nativeSessionId,
|
|
671
|
+
commandKey: target.commandKey,
|
|
672
|
+
cwd: opts.cwd,
|
|
673
|
+
expectedPid: opts.expectedPid,
|
|
674
|
+
expectedStartedAt: opts.expectedStartedAt,
|
|
675
|
+
});
|
|
676
|
+
ctx.before = {
|
|
677
|
+
observation: matched.observation,
|
|
678
|
+
status: matched.row.status ?? null,
|
|
679
|
+
state: matched.row.state ?? null,
|
|
680
|
+
pid: Number.isSafeInteger(matched.row.pid) ? matched.row.pid : null,
|
|
681
|
+
at: before.captured_at,
|
|
682
|
+
};
|
|
683
|
+
if (matched.observation !== 'active') {
|
|
684
|
+
return {
|
|
685
|
+
...nativePayload(opts, target.nativeSessionId, matched, {
|
|
686
|
+
status: 'not_cancellable',
|
|
687
|
+
blockers: ['native_not_active'],
|
|
688
|
+
observation: matched.observation,
|
|
689
|
+
task_result_claimed: false,
|
|
690
|
+
}),
|
|
691
|
+
reconciliation_required: false,
|
|
692
|
+
claims_retained: true,
|
|
693
|
+
cancelled_message_sent: false,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const { taskId, from, to } = ids(opts);
|
|
697
|
+
const intent = submitMessage(opts.taskRoot, buildMessage({
|
|
698
|
+
type: 'CANCEL',
|
|
699
|
+
task_id: taskId,
|
|
700
|
+
from,
|
|
701
|
+
to,
|
|
702
|
+
subtask_id: opts.subtaskId,
|
|
703
|
+
attempt: opts.attempt,
|
|
704
|
+
owner_epoch: opts.ownerEpoch,
|
|
705
|
+
body: {
|
|
706
|
+
reason: options.reason ?? 'native background-session cancellation',
|
|
707
|
+
native_session_id: target.nativeSessionId,
|
|
708
|
+
native_command_key: target.commandKey,
|
|
709
|
+
native_status: matched.native_status,
|
|
710
|
+
},
|
|
711
|
+
}));
|
|
712
|
+
if (intent.ok !== true) {
|
|
713
|
+
ctx.intent_error = intent.reason ?? 'cancel_intent_not_recorded';
|
|
714
|
+
return {
|
|
715
|
+
...nativePayload(opts, target.nativeSessionId, matched, {
|
|
716
|
+
status: 'invalid',
|
|
717
|
+
blockers: ['cancel_intent_not_recorded'],
|
|
718
|
+
intent_error: ctx.intent_error,
|
|
719
|
+
}),
|
|
720
|
+
reconciliation_required: false,
|
|
721
|
+
claims_retained: true,
|
|
722
|
+
cancelled_message_sent: false,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
ctx.cancel_message_id = intent.registration.message_id;
|
|
726
|
+
ctx.intent_recorded = true;
|
|
727
|
+
const stop = await runBounded(opts.claudeBin, ['stop', target.commandKey], {
|
|
728
|
+
cwd: opts.cwd,
|
|
729
|
+
env: opts.env,
|
|
730
|
+
timeoutMs: opts.timeoutMs,
|
|
731
|
+
maxBytes: opts.maxBytes,
|
|
732
|
+
streamGraceMs: opts.streamGraceMs,
|
|
733
|
+
});
|
|
734
|
+
ctx.stop_command = {
|
|
735
|
+
exit_code: stop.exit_code,
|
|
736
|
+
signal: stop.signal,
|
|
737
|
+
timed_out: stop.timed_out,
|
|
738
|
+
parent_exited: stop.parent_exited,
|
|
739
|
+
streams_detached: stop.streams_detached,
|
|
740
|
+
spawn_failed: stop.spawn_failed,
|
|
741
|
+
spawn_error: stop.spawn_error,
|
|
742
|
+
note: 'exit 0 alone is not stop proof',
|
|
743
|
+
};
|
|
744
|
+
const deadline = Date.now() + opts.stopTimeoutMs;
|
|
745
|
+
let terminal = null;
|
|
746
|
+
for (;;) {
|
|
747
|
+
try {
|
|
748
|
+
const listed = await listSessions(opts);
|
|
749
|
+
const current = matchRow(listed.rows, {
|
|
750
|
+
nativeSessionId: target.nativeSessionId,
|
|
751
|
+
commandKey: target.commandKey,
|
|
752
|
+
cwd: opts.cwd,
|
|
753
|
+
expectedPid: null,
|
|
754
|
+
expectedStartedAt: null,
|
|
755
|
+
});
|
|
756
|
+
ctx.last_observation = {
|
|
757
|
+
observation: current.observation,
|
|
758
|
+
state: current.native_state,
|
|
759
|
+
status: current.native_status,
|
|
760
|
+
at: listed.captured_at,
|
|
761
|
+
};
|
|
762
|
+
if (current.observation === 'terminal') {
|
|
763
|
+
terminal = ctx.last_observation;
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
if (!(error instanceof ClaudeNativeRefusal))
|
|
769
|
+
throw error;
|
|
770
|
+
ctx.last_observation = {
|
|
771
|
+
observation: 'unknown',
|
|
772
|
+
blocker: errorCode(error),
|
|
773
|
+
at: nowIso(),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
if (Date.now() >= deadline)
|
|
777
|
+
break;
|
|
778
|
+
await delay(Math.min(opts.pollMs, Math.max(1, deadline - Date.now())));
|
|
779
|
+
}
|
|
780
|
+
const evidence = {
|
|
781
|
+
request: ['agents --json --all --cwd', 'stop <command-key>'],
|
|
782
|
+
observed_ids: {
|
|
783
|
+
session_id: target.nativeSessionId,
|
|
784
|
+
command_key: target.commandKey,
|
|
785
|
+
},
|
|
786
|
+
cancel_message_id: ctx.cancel_message_id,
|
|
787
|
+
before: ctx.before,
|
|
788
|
+
stop_command: ctx.stop_command,
|
|
789
|
+
last_observation: ctx.last_observation,
|
|
790
|
+
queried_at: nowIso(),
|
|
791
|
+
};
|
|
792
|
+
if (opts.evidenceDir) {
|
|
793
|
+
try {
|
|
794
|
+
fs.mkdirSync(opts.evidenceDir, { recursive: true, mode: 0o700 });
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
/* reported below */
|
|
798
|
+
}
|
|
799
|
+
const written = tryWritePrivate(path.join(opts.evidenceDir, 'claude-native-cancel.json'), Buffer.from(`${JSON.stringify(evidence, null, 2)}\n`));
|
|
800
|
+
if (written.error)
|
|
801
|
+
ctx.evidence_error = written.error;
|
|
802
|
+
else
|
|
803
|
+
evidence.evidence_ref = written;
|
|
804
|
+
}
|
|
805
|
+
if (terminal) {
|
|
806
|
+
return {
|
|
807
|
+
...nativePayload(opts, target.nativeSessionId, matched, {
|
|
808
|
+
status: 'terminal',
|
|
809
|
+
blockers: [],
|
|
810
|
+
observation: 'terminal',
|
|
811
|
+
task_result_claimed: false,
|
|
812
|
+
}),
|
|
813
|
+
native_terminal: terminal,
|
|
814
|
+
evidence,
|
|
815
|
+
evidence_error: ctx.evidence_error,
|
|
816
|
+
cancel_message_id: ctx.cancel_message_id,
|
|
817
|
+
intent_recorded: ctx.intent_recorded,
|
|
818
|
+
reconciliation_required: true,
|
|
819
|
+
claims_retained: true,
|
|
820
|
+
cancelled_message_sent: false,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
...nativePayload(opts, target.nativeSessionId, matched, {
|
|
825
|
+
status: 'needs_input',
|
|
826
|
+
blockers: ['native_terminal_state_not_observed'],
|
|
827
|
+
observation: 'unknown',
|
|
828
|
+
task_result_claimed: false,
|
|
829
|
+
}),
|
|
830
|
+
evidence,
|
|
831
|
+
evidence_error: ctx.evidence_error,
|
|
832
|
+
cancel_message_id: ctx.cancel_message_id,
|
|
833
|
+
intent_recorded: ctx.intent_recorded,
|
|
834
|
+
reconciliation_required: true,
|
|
835
|
+
claims_retained: true,
|
|
836
|
+
cancelled_message_sent: false,
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
return refusalResult(schema, error, {
|
|
841
|
+
cancel_message_id: ctx.cancel_message_id,
|
|
842
|
+
intent_recorded: ctx.intent_recorded,
|
|
843
|
+
intent_error: ctx.intent_error,
|
|
844
|
+
native_command_key: ctx.native_command_key,
|
|
845
|
+
before: ctx.before,
|
|
846
|
+
stop_command: ctx.stop_command,
|
|
847
|
+
last_observation: ctx.last_observation,
|
|
848
|
+
evidence_error: ctx.evidence_error,
|
|
849
|
+
reconciliation_required: ctx.intent_recorded,
|
|
850
|
+
cancelled_message_sent: false,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
}
|