agent-relay-server 0.124.0 → 0.125.0
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/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-SQK8-Mzp.js → MessageBubble-BYeYFteG.js} +2 -2
- package/public/assets/{MessageBubble-SQK8-Mzp.js.map → MessageBubble-BYeYFteG.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-CS-aow4S.js → PlanGraphPanel-iAXvkZKW.js} +2 -2
- package/public/assets/{PlanGraphPanel-CS-aow4S.js.map → PlanGraphPanel-iAXvkZKW.js.map} +1 -1
- package/public/assets/{activity-W31XvhC2.js → activity-Du9SRJt5.js} +2 -2
- package/public/assets/{activity-W31XvhC2.js.map → activity-Du9SRJt5.js.map} +1 -1
- package/public/assets/agent-profiles-DXDQnrlH.js +2 -0
- package/public/assets/{agent-profiles-DXysA9To.js.map → agent-profiles-DXDQnrlH.js.map} +1 -1
- package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-B3i0rndK.js} +2 -2
- package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-B3i0rndK.js.map} +1 -1
- package/public/assets/{agents-B5GRNRnN.js → agents-CWOdKrxP.js} +2 -2
- package/public/assets/{agents-B5GRNRnN.js.map → agents-CWOdKrxP.js.map} +1 -1
- package/public/assets/{analytics-DqAuUAwX.js → analytics-YcY59Hv9.js} +2 -2
- package/public/assets/{analytics-DqAuUAwX.js.map → analytics-YcY59Hv9.js.map} +1 -1
- package/public/assets/{automation-DYlgHYcb.js → automation-CItTse7F.js} +2 -2
- package/public/assets/{automation-DYlgHYcb.js.map → automation-CItTse7F.js.map} +1 -1
- package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-QjTktTYj.js} +2 -2
- package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-QjTktTYj.js.map} +1 -1
- package/public/assets/{channels-DVRkVeky.js → channels-BSFTsbhT.js} +2 -2
- package/public/assets/{channels-DVRkVeky.js.map → channels-BSFTsbhT.js.map} +1 -1
- package/public/assets/chat-CRzFSFWe.js +2 -0
- package/public/assets/chat-CRzFSFWe.js.map +1 -0
- package/public/assets/{connectors-Cqubkw9C.js → connectors-C7d5dlYz.js} +2 -2
- package/public/assets/{connectors-Cqubkw9C.js.map → connectors-C7d5dlYz.js.map} +1 -1
- package/public/assets/{display-GFbGUG0O.js → display-aTnwsqQh.js} +3 -3
- package/public/assets/{display-GFbGUG0O.js.map → display-aTnwsqQh.js.map} +1 -1
- package/public/assets/{formatted-body-BYQ-SPtj.js → formatted-body-BuLfVMeD.js} +3 -3
- package/public/assets/{formatted-body-BYQ-SPtj.js.map → formatted-body-BuLfVMeD.js.map} +1 -1
- package/public/assets/{formatted-body-impl-L1EMnNW6.js → formatted-body-impl-DO-8giS6.js} +2 -2
- package/public/assets/{formatted-body-impl-L1EMnNW6.js.map → formatted-body-impl-DO-8giS6.js.map} +1 -1
- package/public/assets/index-C9X_0QBt.css +2 -0
- package/public/assets/index-De1tR7J4.js +26 -0
- package/public/assets/index-De1tR7J4.js.map +1 -0
- package/public/assets/{insights-DwZxq1Dn.js → insights-Ci3VuA5Y.js} +2 -2
- package/public/assets/{insights-DwZxq1Dn.js.map → insights-Ci3VuA5Y.js.map} +1 -1
- package/public/assets/{integrations-9G5TJnnU.js → integrations-BboxWQQD.js} +2 -2
- package/public/assets/{integrations-9G5TJnnU.js.map → integrations-BboxWQQD.js.map} +1 -1
- package/public/assets/{maintenance-COcmUDnf.js → maintenance-CO1miJIJ.js} +2 -2
- package/public/assets/{maintenance-COcmUDnf.js.map → maintenance-CO1miJIJ.js.map} +1 -1
- package/public/assets/{managed-agents-D9-dgMeX.js → managed-agents-r4oYK3Fm.js} +2 -2
- package/public/assets/{managed-agents-D9-dgMeX.js.map → managed-agents-r4oYK3Fm.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DH372bL-.js → markdown-preview-impl-5CCM9iyL.js} +2 -2
- package/public/assets/{markdown-preview-impl-DH372bL-.js.map → markdown-preview-impl-5CCM9iyL.js.map} +1 -1
- package/public/assets/{memory-B9hRK8sk.js → memory-BJJS07W-.js} +2 -2
- package/public/assets/{memory-B9hRK8sk.js.map → memory-BJJS07W-.js.map} +1 -1
- package/public/assets/{messages-BHkHlXzF.js → messages-RT_dfEsL.js} +2 -2
- package/public/assets/{messages-BHkHlXzF.js.map → messages-RT_dfEsL.js.map} +1 -1
- package/public/assets/{orchestrators-CXy3SDSn.js → orchestrators-8hyl8hW1.js} +2 -2
- package/public/assets/{orchestrators-CXy3SDSn.js.map → orchestrators-8hyl8hW1.js.map} +1 -1
- package/public/assets/{overview-D2yRyCQq.js → overview-CUJ9QmW_.js} +2 -2
- package/public/assets/{overview-D2yRyCQq.js.map → overview-CUJ9QmW_.js.map} +1 -1
- package/public/assets/{pairs-5tXdHylI.js → pairs-CzkzMGZq.js} +2 -2
- package/public/assets/{pairs-5tXdHylI.js.map → pairs-CzkzMGZq.js.map} +1 -1
- package/public/assets/{projects-CquHTMo1.js → projects-CAuLWBU5.js} +2 -2
- package/public/assets/{projects-CquHTMo1.js.map → projects-CAuLWBU5.js.map} +1 -1
- package/public/assets/{prompt-settings-Bn5mmi9w.js → prompt-settings-BnB90R7F.js} +2 -2
- package/public/assets/{prompt-settings-Bn5mmi9w.js.map → prompt-settings-BnB90R7F.js.map} +1 -1
- package/public/assets/{registry-C3sB7qv5.js → registry-B8TInqru.js} +2 -2
- package/public/assets/{registry-C3sB7qv5.js.map → registry-B8TInqru.js.map} +1 -1
- package/public/assets/{security-CmD-YE-_.js → security-DG_VVZUW.js} +2 -2
- package/public/assets/{security-CmD-YE-_.js.map → security-DG_VVZUW.js.map} +1 -1
- package/public/assets/{select-C0UlhK2k.js → select-BJ8G38_V.js} +2 -2
- package/public/assets/{select-C0UlhK2k.js.map → select-BJ8G38_V.js.map} +1 -1
- package/public/assets/{settings-DrqMG5nG.js → settings-CK_T9z5w.js} +4 -4
- package/public/assets/{settings-DrqMG5nG.js.map → settings-CK_T9z5w.js.map} +1 -1
- package/public/assets/{store-zFLzd0Jd.js → store-Dl9Z8YUS.js} +4 -4
- package/public/assets/{store-zFLzd0Jd.js.map → store-Dl9Z8YUS.js.map} +1 -1
- package/public/assets/{tasks-ChQjtXHu.js → tasks-Briui9Mp.js} +2 -2
- package/public/assets/{tasks-ChQjtXHu.js.map → tasks-Briui9Mp.js.map} +1 -1
- package/public/assets/{teams-itjNlDLp.js → teams-Ck4EVV3R.js} +2 -2
- package/public/assets/{teams-itjNlDLp.js.map → teams-Ck4EVV3R.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-B9M6s2vB.js → terminal-viewer-impl-66ZjVN1v.js} +2 -2
- package/public/assets/{terminal-viewer-impl-B9M6s2vB.js.map → terminal-viewer-impl-66ZjVN1v.js.map} +1 -1
- package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
- package/public/assets/{types-CRptbSYh.js.map → types-BqeSkWrv.js.map} +1 -1
- package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-BOy9o2xK.js} +2 -2
- package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-BOy9o2xK.js.map} +1 -1
- package/public/assets/{work-queue-Kw0uMkEF.js → work-queue-CnghFRG2.js} +2 -2
- package/public/assets/{work-queue-Kw0uMkEF.js.map → work-queue-CnghFRG2.js.map} +1 -1
- package/public/assets/{workspaces-CR6f7G0R.js → workspaces-BzmQnzSm.js} +2 -2
- package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-BzmQnzSm.js.map} +1 -1
- package/public/index.html +7 -7
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/hooks/relay-status.sh +11 -1
- package/runner/plugins/claude/hooks/stop.sh +6 -1
- package/runner/plugins/claude/hooks/user-prompt-submit.sh +6 -1
- package/runner/src/adapter.ts +5 -1
- package/runner/src/adapters/claude-transcript.ts +52 -58
- package/runner/src/adapters/codex-agent-message-capture.ts +8 -36
- package/runner/src/adapters/codex.ts +30 -12
- package/runner/src/control-server.ts +9 -4
- package/runner/src/response-capture-report.ts +4 -1
- package/runner/src/runner-core.ts +75 -33
- package/src/agent-lifecycle-events.ts +8 -0
- package/src/bus.ts +0 -24
- package/src/db/agents.ts +15 -1
- package/src/db/messages.ts +114 -43
- package/src/maintenance/jobs.ts +8 -2
- package/src/mcp/tools-workspace.ts +4 -0
- package/src/routes/agents.ts +0 -4
- package/src/services/send-message.ts +7 -18
- package/src/services/shutdown-agent.ts +6 -0
- package/src/services/task-entity.ts +4 -0
- package/src/services/transient-agent-reaper.ts +40 -1
- package/src/services/work-finality.ts +25 -0
- package/public/assets/agent-profiles-DXysA9To.js +0 -2
- package/public/assets/chat-Clz6qOtn.js +0 -2
- package/public/assets/chat-Clz6qOtn.js.map +0 -1
- package/public/assets/index-0uIGXT14.js +0 -25
- package/public/assets/index-0uIGXT14.js.map +0 -1
- package/public/assets/index-DOJ7KidA.css +0 -2
|
@@ -115,6 +115,7 @@ relay_post_session_turn() {
|
|
|
115
115
|
# below, so the agent is not nagged to /reply (and does not re-emit).
|
|
116
116
|
local transcript_path="${1:-}"
|
|
117
117
|
local last_assistant_message="${2:-}"
|
|
118
|
+
local prompt_id="${3:-}"
|
|
118
119
|
local port="${AGENT_RELAY_RUNNER_PORT:-}"
|
|
119
120
|
[ -z "$port" ] && return 0
|
|
120
121
|
# transcript_path is the primary capture source, but the Stop payload can omit it
|
|
@@ -127,6 +128,13 @@ relay_post_session_turn() {
|
|
|
127
128
|
[ "$body" != "{" ] && body="${body},"
|
|
128
129
|
body="${body}\"lastAssistantMessage\":${last_assistant_message}"
|
|
129
130
|
fi
|
|
131
|
+
# prompt_id (#1086): Claude's native turn identifier — the stable id the runner
|
|
132
|
+
# uses instead of a minted random UUID, and the deterministic key it slices the
|
|
133
|
+
# transcript by for full-mode enrichment.
|
|
134
|
+
if [ -n "$prompt_id" ]; then
|
|
135
|
+
[ "$body" != "{" ] && body="${body},"
|
|
136
|
+
body="${body}\"promptId\":\"$(relay_json_escape "$prompt_id")\""
|
|
137
|
+
fi
|
|
130
138
|
body="${body}}"
|
|
131
139
|
curl -fsS -X POST "http://127.0.0.1:${port}/session-turn" \
|
|
132
140
|
-H 'Content-Type: application/json' \
|
|
@@ -144,7 +152,9 @@ relay_post_user_prompt() {
|
|
|
144
152
|
[ -z "$payload" ] && return 0
|
|
145
153
|
command -v jq >/dev/null 2>&1 || return 0
|
|
146
154
|
local body
|
|
147
|
-
|
|
155
|
+
# promptId (#1086): Claude's native prompt_id for the turn this prompt starts —
|
|
156
|
+
# the stable turn identifier the runner uses instead of minting a random UUID.
|
|
157
|
+
body="$(printf '%s' "$payload" | jq -c '{prompt: (.prompt // ""), transcriptPath: (.transcript_path // ""), promptId: (.prompt_id // "")}' 2>/dev/null || true)"
|
|
148
158
|
[ -z "$body" ] && return 0
|
|
149
159
|
case "$body" in *'"prompt":""'*) return 0 ;; esac
|
|
150
160
|
curl -fsS --max-time 3 -X POST "http://127.0.0.1:${port}/user-prompt" \
|
|
@@ -23,7 +23,12 @@ bg_count="$(relay_background_task_count "$payload")"
|
|
|
23
23
|
# (stop_hook_active=true) must NOT suppress capturing this turn's response, or the live mirror
|
|
24
24
|
# silently drops frames. Always post the turn; only the reply-obligation decision is gated below.
|
|
25
25
|
last_assistant_msg="$(echo "$payload" | jq -c '.last_assistant_message // empty' 2>/dev/null || true)"
|
|
26
|
-
|
|
26
|
+
# prompt_id (#1086): Claude's own native turn identifier, correlating this Stop with
|
|
27
|
+
# the same UUID stamped on every transcript entry of the turn (and the id the
|
|
28
|
+
# UserPromptSubmit hook reported at turn start) — the stable replacement for a
|
|
29
|
+
# runner-minted random UUID.
|
|
30
|
+
prompt_id="$(relay_json_string_field prompt_id "$payload")"
|
|
31
|
+
relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")" "$last_assistant_msg" "$prompt_id"
|
|
27
32
|
|
|
28
33
|
if [ "$stop_hook_active" != "true" ]; then
|
|
29
34
|
# `|| true`: under `set -e`, a non-zero from the obligation check must never abort
|
|
@@ -3,7 +3,12 @@ set -euo pipefail
|
|
|
3
3
|
source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks/relay-status.sh"
|
|
4
4
|
relay_install_hook_guard user-prompt-submit
|
|
5
5
|
payload="$(cat || true)"
|
|
6
|
-
|
|
6
|
+
# prompt_id (#1086): Claude's own native turn identifier for the prompt starting
|
|
7
|
+
# now — the same UUID the Stop hook reports and the transcript stamps on every
|
|
8
|
+
# entry of this turn. Passed as the busy report's `id` so the runner adopts it as
|
|
9
|
+
# currentTurnId instead of minting a random UUID (runner-core.ts setProviderStatus).
|
|
10
|
+
prompt_id="$(relay_json_string_field prompt_id "$payload")"
|
|
11
|
+
relay_post_status busy "" "$prompt_id"
|
|
7
12
|
# Mirror a terminal/TUI-typed prompt into the dashboard chat and start reasoning
|
|
8
13
|
# tailing for this turn. No-op for prompts the runner injected (chat box / relay).
|
|
9
14
|
relay_post_user_prompt "$payload"
|
package/runner/src/adapter.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
|
|
1
|
+
import type { AgentProfile, LivenessSignal, Message, MessageFinalityConfidence, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
|
|
2
2
|
import { isRecord } from "agent-relay-sdk/types/guards";
|
|
3
3
|
import type { LivenessInputs } from "./liveness";
|
|
4
4
|
import type { SessionEvent } from "./session-insights";
|
|
@@ -50,6 +50,10 @@ export interface ProviderSessionEvent {
|
|
|
50
50
|
turnId?: string;
|
|
51
51
|
/** True only when this response is the completed provider turn, not an intermediate flush. */
|
|
52
52
|
final?: boolean;
|
|
53
|
+
/** Whether the turn-final edge was provider-native or reconstructed. Set only alongside `final`. */
|
|
54
|
+
confidence?: MessageFinalityConfidence;
|
|
55
|
+
/** Why the provider turn ended. Set only alongside `final`. */
|
|
56
|
+
endedBy?: MessageTurnEndReason;
|
|
53
57
|
label?: string;
|
|
54
58
|
status?: "running" | "completed" | "failed";
|
|
55
59
|
streaming?: boolean;
|
|
@@ -46,6 +46,16 @@ interface TranscriptEntry {
|
|
|
46
46
|
// Events/countSubstantiveTurns) intentionally do NOT filter — changing them
|
|
47
47
|
// would shift the #184/#185 baselines, a separate concern.
|
|
48
48
|
isSidechain?: boolean;
|
|
49
|
+
// Every entry carries its own `uuid` and its `parentUuid` (the entry it was
|
|
50
|
+
// appended after), forming a causal chain for the whole session. A `user`-type
|
|
51
|
+
// entry — whether the real prompt or a synthetic tool_result — is stamped with
|
|
52
|
+
// `promptId`: the same UUID Claude's Stop-hook payload reports as `prompt_id`,
|
|
53
|
+
// shared by every entry from that prompt until the next one. Assistant entries
|
|
54
|
+
// don't carry `promptId` directly; their turn is resolved by walking `parentUuid`
|
|
55
|
+
// back to the nearest ancestor that has one (see resolveEntryPromptId).
|
|
56
|
+
uuid?: string;
|
|
57
|
+
parentUuid?: string;
|
|
58
|
+
promptId?: string;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
|
|
@@ -147,59 +157,39 @@ export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: nu
|
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
160
|
+
* Resolve the `promptId` (Stop hook's `prompt_id`) a transcript entry belongs to.
|
|
161
|
+
* A `user`-type entry (the real prompt, or a synthetic tool_result) carries its own
|
|
162
|
+
* `promptId`; an assistant entry doesn't, so its turn is resolved by walking
|
|
163
|
+
* `parentUuid` back through the causal chain until an ancestor with one is found.
|
|
164
|
+
* Returns undefined for an entry with no resolvable ancestor (e.g. the very first
|
|
165
|
+
* turn of a process, before any `promptId` has ever been stamped).
|
|
152
166
|
*/
|
|
153
|
-
|
|
154
|
-
let
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Returns only the text from the LAST assistant entry in the current turn.
|
|
164
|
-
* Unlike extractLastAssistantTurn which collects all intermediate text between
|
|
165
|
-
* tool calls, this returns just the final response — what the user sees as the
|
|
166
|
-
* concluding message.
|
|
167
|
-
*/
|
|
168
|
-
export function extractFinalAssistantMessage(jsonl: string): string {
|
|
169
|
-
const lines = jsonl.split("\n");
|
|
170
|
-
let lastText = "";
|
|
171
|
-
let pastLastUserPrompt = false;
|
|
172
|
-
for (const line of lines) {
|
|
173
|
-
const trimmed = line.trim();
|
|
174
|
-
if (!trimmed) continue;
|
|
175
|
-
let entry: TranscriptEntry;
|
|
176
|
-
try {
|
|
177
|
-
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
178
|
-
} catch {
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
if (isSidechainEntry(entry)) continue;
|
|
182
|
-
if (isRealUserPrompt(entry)) {
|
|
183
|
-
pastLastUserPrompt = true;
|
|
184
|
-
lastText = "";
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
if (!pastLastUserPrompt) continue;
|
|
188
|
-
const text = assistantText(entry);
|
|
189
|
-
if (text) lastText = text;
|
|
167
|
+
function resolveEntryPromptId(entry: TranscriptEntry, byUuid: ReadonlyMap<string, TranscriptEntry>): string | undefined {
|
|
168
|
+
let current: TranscriptEntry | undefined = entry;
|
|
169
|
+
const visited = new Set<string>();
|
|
170
|
+
while (current) {
|
|
171
|
+
if (typeof current.promptId === "string" && current.promptId) return current.promptId;
|
|
172
|
+
const parentUuid = current.parentUuid;
|
|
173
|
+
if (!parentUuid || visited.has(parentUuid)) return undefined;
|
|
174
|
+
visited.add(parentUuid);
|
|
175
|
+
current = byUuid.get(parentUuid);
|
|
190
176
|
}
|
|
191
|
-
return
|
|
177
|
+
return undefined;
|
|
192
178
|
}
|
|
193
179
|
|
|
194
180
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
181
|
+
* Deterministic replacement for extractLastAssistantTurnAfterEntry's "walk back to
|
|
182
|
+
* the last real user prompt" heuristic: collects assistant `text` blocks for entries
|
|
183
|
+
* whose resolved promptId (see resolveEntryPromptId) equals the Claude-native
|
|
184
|
+
* `promptId` the Stop hook reported for this turn. Skips the first `afterEntry`
|
|
185
|
+
* entries the same way, for pre-flush resume. Since entries never move once
|
|
186
|
+
* written, a turn's promptId membership is exact — no text-shape guessing about
|
|
187
|
+
* what counts as a "real" prompt.
|
|
198
188
|
*/
|
|
199
|
-
export function
|
|
189
|
+
export function extractAssistantTurnForPrompt(jsonl: string, promptId: string, afterEntry = 0): string {
|
|
200
190
|
const lines = jsonl.split("\n");
|
|
201
|
-
|
|
202
|
-
|
|
191
|
+
const byUuid = new Map<string, TranscriptEntry>();
|
|
192
|
+
const collected: string[] = [];
|
|
203
193
|
let entryIndex = 0;
|
|
204
194
|
for (const line of lines) {
|
|
205
195
|
const trimmed = line.trim();
|
|
@@ -211,26 +201,30 @@ export function extractFinalAssistantMessageAfterEntry(jsonl: string, afterEntry
|
|
|
211
201
|
continue;
|
|
212
202
|
}
|
|
213
203
|
entryIndex++;
|
|
204
|
+
if (typeof entry.uuid === "string" && entry.uuid) byUuid.set(entry.uuid, entry);
|
|
214
205
|
if (isSidechainEntry(entry)) continue;
|
|
215
206
|
if (entryIndex <= afterEntry) continue;
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
lastText = "";
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
if (!pastLastUserPrompt) continue;
|
|
207
|
+
if (entry.type !== "assistant") continue;
|
|
208
|
+
if (resolveEntryPromptId(entry, byUuid) !== promptId) continue;
|
|
222
209
|
const text = assistantText(entry);
|
|
223
|
-
if (text)
|
|
210
|
+
if (text) collected.push(text);
|
|
224
211
|
}
|
|
225
|
-
return
|
|
212
|
+
return collected.join("\n\n").trim();
|
|
226
213
|
}
|
|
227
214
|
|
|
228
215
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* string or an array of content blocks (same shape as transcript entries).
|
|
232
|
-
* Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
|
|
216
|
+
* Count valid JSON entries in a JSONL string. Used to record the high-water-mark
|
|
217
|
+
* after a pre-flush so the Stop hook knows where to resume in full-capture mode.
|
|
233
218
|
*/
|
|
219
|
+
export function countTranscriptEntries(jsonl: string): number {
|
|
220
|
+
let count = 0;
|
|
221
|
+
for (const line of jsonl.split("\n")) {
|
|
222
|
+
if (!line.trim()) continue;
|
|
223
|
+
try { JSON.parse(line); count++; } catch {}
|
|
224
|
+
}
|
|
225
|
+
return count;
|
|
226
|
+
}
|
|
227
|
+
|
|
234
228
|
/**
|
|
235
229
|
* Extract the ordered narration, reasoning, and tool steps for the most recent
|
|
236
230
|
* turn (since the last real user prompt). Used by the reasoning tailer to stream
|
|
@@ -4,7 +4,6 @@ type CompleteItemOptions = { includeInResponse?: boolean; phase?: unknown };
|
|
|
4
4
|
|
|
5
5
|
const MAX_CAPTURED_RESPONSE_CHARS = 16_000;
|
|
6
6
|
const TRUNCATION_MARKER_PREFIX = "[truncated:";
|
|
7
|
-
const TERSE_FINAL_MAX_CHARS = 280;
|
|
8
7
|
|
|
9
8
|
export class CodexAgentMessageCapture {
|
|
10
9
|
private segments: ResponseEntry[][] = [];
|
|
@@ -46,11 +45,17 @@ export class CodexAgentMessageCapture {
|
|
|
46
45
|
this.currentSegment = [];
|
|
47
46
|
}
|
|
48
47
|
|
|
48
|
+
// Deterministic turn-final response: the TRAILING agentMessage run only — all items after
|
|
49
|
+
// the LAST non-message item (tool call, file change, reasoning...) in the turn (design
|
|
50
|
+
// #1079 §3.2). Earlier agentMessage segments are mid-work narration, already mirrored live
|
|
51
|
+
// via `emit`, and must not be re-injected into the final (#1083 Finding 2). Codex's own
|
|
52
|
+
// phase:"commentary" tag (see completeItem above) still trims commentary items out of
|
|
53
|
+
// whichever segment they land in, but is not the sole guard against leading narration.
|
|
49
54
|
finish(emit: EmitAgentMessage): string | undefined {
|
|
50
55
|
this.closeSegment(emit);
|
|
51
|
-
const
|
|
56
|
+
const trailingSegment = this.segments.at(-1);
|
|
52
57
|
this.segments = [];
|
|
53
|
-
const text =
|
|
58
|
+
const text = trailingSegment ? segmentBody(trailingSegment) : undefined;
|
|
54
59
|
return truncateCapturedResponse(text);
|
|
55
60
|
}
|
|
56
61
|
|
|
@@ -96,43 +101,10 @@ export class CodexAgentMessageCapture {
|
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
function selectResponseSegments(segments: ResponseEntry[][]): ResponseEntry[][] {
|
|
100
|
-
const nonEmptySegments = segments.filter((segment) => segment.length > 0);
|
|
101
|
-
const finalSegment = nonEmptySegments.at(-1);
|
|
102
|
-
if (!finalSegment) return [];
|
|
103
|
-
const finalBody = segmentBody(finalSegment);
|
|
104
|
-
if (!isTerseTerminalStatus(finalBody)) return [finalSegment];
|
|
105
|
-
|
|
106
|
-
// Codex often posts the real wrap-up across multiple assistant segments before a
|
|
107
|
-
// final workspace-ready/status line. Keep contiguous substantive segments after
|
|
108
|
-
// the last procedural assistant segment, then append the terminal status.
|
|
109
|
-
const selectedSegments: ResponseEntry[][] = [];
|
|
110
|
-
for (let index = nonEmptySegments.length - 2; index >= 0; index -= 1) {
|
|
111
|
-
const candidate = nonEmptySegments[index];
|
|
112
|
-
if (!candidate || !isSubstantiveResponseSegment(segmentBody(candidate))) break;
|
|
113
|
-
selectedSegments.unshift(candidate);
|
|
114
|
-
}
|
|
115
|
-
return [...selectedSegments, finalSegment];
|
|
116
|
-
}
|
|
117
|
-
|
|
118
104
|
function segmentBody(segment: ResponseEntry[]): string {
|
|
119
105
|
return segment.map((entry) => entry.body).join("\n\n").trim();
|
|
120
106
|
}
|
|
121
107
|
|
|
122
|
-
function isTerseTerminalStatus(body: string): boolean {
|
|
123
|
-
if (body.length > TERSE_FINAL_MAX_CHARS) return false;
|
|
124
|
-
if (/\n\s*(?:[-*]|\d+\.)\s+/.test(body)) return false;
|
|
125
|
-
return /\b(?:landed|ready for review|handed off|no further action|done|complete|completed|committed|merged)\b/i.test(body);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function isSubstantiveResponseSegment(body: string): boolean {
|
|
129
|
-
if (body.length >= 120) return true;
|
|
130
|
-
if (/\n\s*(?:[-*]|\d+\.)\s+/.test(body)) return true;
|
|
131
|
-
if (/\*\*(?:what changed|changes?|tests?|gates?|verification|risks?|residual risk|summary)\b/i.test(body)) return true;
|
|
132
|
-
if (/^#{1,3}\s+\S/m.test(body)) return true;
|
|
133
|
-
return body.split(/\n{2,}/).length >= 2;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
108
|
function truncateCapturedResponse(text: string | undefined): string | undefined {
|
|
137
109
|
if (!text || text.length <= MAX_CAPTURED_RESPONSE_CHARS) return text;
|
|
138
110
|
return `${text.slice(0, MAX_CAPTURED_RESPONSE_CHARS).trimEnd()}\n\n${TRUNCATION_MARKER_PREFIX} showing first ${MAX_CAPTURED_RESPONSE_CHARS} of ${text.length} chars]`;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { basename, join, resolve } from "node:path";
|
|
4
|
-
import type { ContextState, InteractivePrompt, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
|
|
4
|
+
import type { ContextState, InteractivePrompt, LivenessSignal, Message, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
|
|
5
5
|
import { isRecord, stringValue } from "agent-relay-sdk";
|
|
6
6
|
import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
|
|
7
7
|
import { profileAllowsRelayFeature, providerMessageText, relayContext, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
|
|
@@ -144,13 +144,13 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
144
144
|
const turnStatus = isRecord(activeTurn) ? stringValue(activeTurn.status) : undefined;
|
|
145
145
|
if (turnStatus === "inProgress") return "busy";
|
|
146
146
|
if (turnStatus === "completed" || turnStatus === "interrupted" || turnStatus === "failed") {
|
|
147
|
-
this.finishMainTurn();
|
|
147
|
+
this.finishMainTurn(codexEndedByFrom(turnStatus));
|
|
148
148
|
return "idle";
|
|
149
149
|
}
|
|
150
150
|
const threadStatus = statusType(thread?.status);
|
|
151
151
|
if (threadStatus === "active") return "busy";
|
|
152
152
|
if (threadStatus === "idle" || threadStatus === "notLoaded" || threadStatus === "systemError") {
|
|
153
|
-
this.finishMainTurn();
|
|
153
|
+
this.finishMainTurn(codexEndedByFrom(threadStatus));
|
|
154
154
|
return "idle";
|
|
155
155
|
}
|
|
156
156
|
} catch {
|
|
@@ -508,7 +508,7 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
508
508
|
if (threadId && this.subagentThreads.has(threadId)) {
|
|
509
509
|
this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
|
|
510
510
|
} else {
|
|
511
|
-
this.finishMainTurn();
|
|
511
|
+
this.finishMainTurn(codexEndedByFrom(method));
|
|
512
512
|
}
|
|
513
513
|
}
|
|
514
514
|
if ((method.includes("thread/compacted") || method.includes("thread.compacted")) && threadId) {
|
|
@@ -527,15 +527,16 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
527
527
|
if (status === "idle" || status === "notLoaded") this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
|
|
528
528
|
} else {
|
|
529
529
|
if (status === "active") this.statusCb({ status: "busy", reason: "provider-turn", providerState: this.providerStateFromThreadStatus(params?.status, params) });
|
|
530
|
-
if (status === "idle" || status === "notLoaded" || status === "systemError") this.finishMainTurn();
|
|
530
|
+
if (status === "idle" || status === "notLoaded" || status === "systemError") this.finishMainTurn(codexEndedByFrom(status));
|
|
531
531
|
}
|
|
532
532
|
}
|
|
533
533
|
}
|
|
534
534
|
|
|
535
535
|
// Turn one completed Codex thread item into a session-mirror event. agentMessage text is
|
|
536
|
-
// mirrored as narration as it lands; the captured turn-final response repeats the
|
|
537
|
-
//
|
|
538
|
-
//
|
|
536
|
+
// mirrored as narration as it lands; the captured turn-final response repeats only the
|
|
537
|
+
// TRAILING agentMessage run — the segment after the last tool/file-change/reasoning
|
|
538
|
+
// boundary (design #1079 §3.2) — never earlier mid-work narration, which was already
|
|
539
|
+
// mirrored live. The rest (user prompt echo, reasoning, tool steps) is surfaced as it lands.
|
|
539
540
|
private handleCodexItem(item: Record<string, unknown> | undefined): void {
|
|
540
541
|
if (!item) return;
|
|
541
542
|
const type = stringValue(item.type);
|
|
@@ -676,14 +677,23 @@ export class CodexAdapter implements ProviderAdapter {
|
|
|
676
677
|
if (method.includes("outputDelta") || method.includes("output_delta") || method.includes("progress")) return;
|
|
677
678
|
}
|
|
678
679
|
|
|
679
|
-
|
|
680
|
+
// A turn-final capture is emitted ONLY for a genuine `completed` turn (design #1079 §3.2):
|
|
681
|
+
// failed/interrupted turns must never produce a turn-final. Every "response"-type
|
|
682
|
+
// ProviderSessionEvent is unconditionally dual-stamped turnFinal/final downstream
|
|
683
|
+
// (publishCapturedResponse's provider-agnostic stamp), so the only way to keep a
|
|
684
|
+
// failed/interrupted partial OUT of D's work-final ring is to never emit it as a
|
|
685
|
+
// "response" event at all (#1083 Finding 1). The partial text was already mirrored live
|
|
686
|
+
// as narration by the `emit` callback inside agentMessageCapture.finish(), so it's still
|
|
687
|
+
// visible in the dashboard.
|
|
688
|
+
private flushTurnResponse(endedBy: MessageTurnEndReason): void {
|
|
680
689
|
const text = this.agentMessageCapture.finish((body) => this.emitAgentNarration(body));
|
|
681
|
-
if (text
|
|
690
|
+
if (!text || endedBy !== "completed") return;
|
|
691
|
+
this.sessionEventCb({ type: "response", origin: "provider", body: text, final: true, confidence: "definitive", endedBy, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
|
|
682
692
|
}
|
|
683
693
|
|
|
684
|
-
private finishMainTurn(): void {
|
|
694
|
+
private finishMainTurn(endedBy: MessageTurnEndReason): void {
|
|
685
695
|
this.flushBufferedReasoning(); // surface any trailing reasoning before the closing response
|
|
686
|
-
this.flushTurnResponse();
|
|
696
|
+
this.flushTurnResponse(endedBy);
|
|
687
697
|
const turnId = this.activeTurnId;
|
|
688
698
|
this.activeTurnId = undefined;
|
|
689
699
|
this.pendingApprovals.clear();
|
|
@@ -1151,6 +1161,14 @@ function statusType(value: unknown): string | undefined {
|
|
|
1151
1161
|
return isRecord(value) ? stringValue(value.type) : undefined;
|
|
1152
1162
|
}
|
|
1153
1163
|
|
|
1164
|
+
// Maps any Codex turn-ending signal — a turn/failed|interrupted (dot-notation too) app-server
|
|
1165
|
+
// method name, a turn status ("completed"/"interrupted"/"failed"), or a thread status
|
|
1166
|
+
// ("idle"/"notLoaded"/"systemError") — to the Part A (#1082) turn-end reason. "completed" is
|
|
1167
|
+
// the default for every signal that isn't explicitly a failure or an interruption.
|
|
1168
|
+
function codexEndedByFrom(value: string | undefined): MessageTurnEndReason {
|
|
1169
|
+
return value === "systemError" || value?.includes("failed") ? "failed" : value?.includes("interrupted") ? "interrupted" : "completed";
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1154
1172
|
function activeFlags(value: unknown): string[] {
|
|
1155
1173
|
if (!isRecord(value) || !Array.isArray(value.activeFlags)) return [];
|
|
1156
1174
|
return value.activeFlags.filter((flag): flag is string => typeof flag === "string" && flag.length > 0);
|
|
@@ -49,12 +49,15 @@ interface ControlServerOptions {
|
|
|
49
49
|
// dashboard chat without the agent re-emitting it via /reply.
|
|
50
50
|
// isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
|
|
51
51
|
// un-mirrored narrative before the interactive form appears in chat (#435).
|
|
52
|
-
|
|
52
|
+
// promptId (#1086): Claude's native `prompt_id` from the Stop hook payload — the
|
|
53
|
+
// stable, provider-native turn identifier used instead of a minted random UUID.
|
|
54
|
+
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void>;
|
|
53
55
|
onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
|
|
54
56
|
// A provider UserPromptSubmit hook hands over the prompt the human typed
|
|
55
57
|
// directly into the session (web terminal / TUI) so the runner can mirror it
|
|
56
58
|
// into the dashboard chat and start tailing the turn transcript for reasoning.
|
|
57
|
-
|
|
59
|
+
// promptId (#1086): Claude's native `prompt_id` for the turn this prompt starts.
|
|
60
|
+
onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
|
|
58
61
|
// A provider session-boundary hook (Claude PreCompact / SessionEnd) signals an imminent
|
|
59
62
|
// context reset or termination so the runner can run end-of-session work (#183 pre-destroy
|
|
60
63
|
// seam: #184 context-ratio capture) before the invasive operation. `reason` is the raw
|
|
@@ -501,6 +504,7 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
|
|
|
501
504
|
const body = await req.json().catch(() => null);
|
|
502
505
|
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
|
|
503
506
|
const lastAssistantMessage = isRecord(body) ? body.lastAssistantMessage : undefined;
|
|
507
|
+
const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
|
|
504
508
|
// transcriptPath is the primary source, but it can be absent (known CC Stop edge case).
|
|
505
509
|
// lastAssistantMessage is the fallback for exactly that — accept either; reject only when
|
|
506
510
|
// both are missing so the turn still reaches the dashboard chat (#264).
|
|
@@ -511,7 +515,7 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
|
|
|
511
515
|
// reply-obligation check, so the captured turn must be persisted (and the
|
|
512
516
|
// obligation cleared) before this response returns.
|
|
513
517
|
try {
|
|
514
|
-
await options.onSessionTurn({ transcriptPath, lastAssistantMessage });
|
|
518
|
+
await options.onSessionTurn({ transcriptPath, lastAssistantMessage, ...(promptId ? { promptId } : {}) });
|
|
515
519
|
return Response.json({ ok: true });
|
|
516
520
|
} catch (error) {
|
|
517
521
|
return Response.json({ ok: false, reason: errMessage(error) }, { status: 500 });
|
|
@@ -524,8 +528,9 @@ async function handleUserPrompt(req: Request, options: ControlServerOptions): Pr
|
|
|
524
528
|
const prompt = isRecord(body) && typeof body.prompt === "string" ? body.prompt : "";
|
|
525
529
|
if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
|
|
526
530
|
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
|
|
531
|
+
const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
|
|
527
532
|
// Fire-and-forget: the hook must not block Claude's turn on relay round-trips.
|
|
528
|
-
void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath })).catch(() => {});
|
|
533
|
+
void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath, ...(promptId ? { promptId } : {}) })).catch(() => {});
|
|
529
534
|
return Response.json({ ok: true });
|
|
530
535
|
}
|
|
531
536
|
|
|
@@ -12,8 +12,11 @@ type SessionPublisher = (input: {
|
|
|
12
12
|
replyTo?: number;
|
|
13
13
|
}) => void | Promise<void>;
|
|
14
14
|
|
|
15
|
+
// #1086: dual-stamp the Part A (#1082) turn-finality field alongside the legacy
|
|
16
|
+
// `final` alias so old consumers keep working while new ones can key off the
|
|
17
|
+
// typed `turnFinal` flag directly instead of every writer's own `final: true`.
|
|
15
18
|
function finalSession(session: MessageSessionMeta): MessageSessionMeta {
|
|
16
|
-
return { ...session, final: true };
|
|
19
|
+
return { ...session, turnFinal: true, final: true };
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export async function publishCapturedResponse(input: {
|