@oar-sh/oar 0.9.1 → 0.9.3
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/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
|
@@ -9,15 +9,16 @@ import { fileURLToPath } from 'url';
|
|
|
9
9
|
import {
|
|
10
10
|
loadTokenFromConfig,
|
|
11
11
|
resolveRelayServerUrl,
|
|
12
|
-
} from '
|
|
13
|
-
import { createApiClient } from '
|
|
14
|
-
import { createWorkerWebSocketLink } from '
|
|
15
|
-
import { createHeartbeatController } from '
|
|
12
|
+
} from '../../shared/worker-runtime/config-loader.mjs';
|
|
13
|
+
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
14
|
+
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
15
|
+
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
16
16
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
17
17
|
import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
|
|
18
18
|
import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
|
|
19
19
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
20
20
|
import { createClaudeSessionRunner } from './claude-session-process.mjs';
|
|
21
|
+
import { createRunnerLinkBridge, failSettlingRowsOnShutdown } from './claude-worker-link-wiring.mjs';
|
|
21
22
|
|
|
22
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
24
|
const HEARTBEAT_MS = 10_000;
|
|
@@ -82,6 +83,9 @@ async function main() {
|
|
|
82
83
|
onAbortSubagent: (subagentRunId) => turnRunner.stopBackgroundTaskByToolUseId(subagentRunId),
|
|
83
84
|
dbg,
|
|
84
85
|
});
|
|
86
|
+
const linkBridge = createRunnerLinkBridge();
|
|
87
|
+
// Late-bound: the heartbeat controller is built after the runner.
|
|
88
|
+
let heartbeat = null;
|
|
85
89
|
const turnRunner = createClaudeSessionRunner({
|
|
86
90
|
api,
|
|
87
91
|
sdkSessionId,
|
|
@@ -102,18 +106,37 @@ async function main() {
|
|
|
102
106
|
getBackgroundTaskTimeoutMs: () => backgroundTaskTimeoutMs,
|
|
103
107
|
getAutoCompactWindow: () => autoCompactWindow,
|
|
104
108
|
getThinking: () => thinking,
|
|
109
|
+
// A readiness flip is a hold starting or ending (question card,
|
|
110
|
+
// compaction, a turn-opening delivery): the steering snapshot changed
|
|
111
|
+
// with it, so it goes out now rather than on the next 10 s heartbeat.
|
|
112
|
+
onDeliveryReadinessChange: (ready) => {
|
|
113
|
+
linkBridge.onDeliveryReadinessChange(ready);
|
|
114
|
+
heartbeat?.requestPulse();
|
|
115
|
+
},
|
|
116
|
+
canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
|
|
105
117
|
dbg,
|
|
106
118
|
});
|
|
107
119
|
|
|
108
120
|
let heartbeatTimer = null;
|
|
109
|
-
|
|
121
|
+
heartbeat = createHeartbeatController({
|
|
110
122
|
api,
|
|
111
123
|
pollMs: HEARTBEAT_MS,
|
|
112
124
|
getSessionReady: () => true,
|
|
113
125
|
getHeartbeatTimer: () => heartbeatTimer,
|
|
114
126
|
setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
|
|
115
127
|
getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
|
|
116
|
-
|
|
128
|
+
// The runner reports { id, attemptId } entries; the heartbeat's claim
|
|
129
|
+
// payload is id-only (its String() coercion would mangle an object), while
|
|
130
|
+
// the crash guard below takes the entries whole so its requeues stay
|
|
131
|
+
// fenced to this attempt.
|
|
132
|
+
getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds().map((entry) => entry.id),
|
|
133
|
+
// Composer steering snapshot. Holds reach the relay within a pulse window
|
|
134
|
+
// (onDeliveryReadinessChange above) and a relay that just came up gets it
|
|
135
|
+
// on its first hello; the periodic beat only backstops the rest.
|
|
136
|
+
getSteeringState: () => turnRunner.steeringState(),
|
|
137
|
+
// Consumed rows the runner could not fail itself; the relay fails them.
|
|
138
|
+
getSettleFailed: () => turnRunner.getSettleFailed(),
|
|
139
|
+
onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
|
|
117
140
|
});
|
|
118
141
|
|
|
119
142
|
const wsLink = createWorkerWebSocketLink({
|
|
@@ -121,6 +144,18 @@ async function main() {
|
|
|
121
144
|
token,
|
|
122
145
|
dbg,
|
|
123
146
|
getSessionReady: () => true,
|
|
147
|
+
// Mid-turn steering: while a turn is running and the runner can absorb
|
|
148
|
+
// another message into it, the link keeps signalling readiness, so a
|
|
149
|
+
// message sent while Claude is thinking is pushed into the live turn
|
|
150
|
+
// instead of waiting for the result (docs/plans/2026-09-19-claude-mid-
|
|
151
|
+
// turn-steering.md).
|
|
152
|
+
getSteeringReady: () => turnRunner.canAcceptSteering(),
|
|
153
|
+
// A hold (open question card, compaction, adoption) suppresses readiness
|
|
154
|
+
// even between deliveries — a delivery drawn into it is only handed back.
|
|
155
|
+
getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
|
|
156
|
+
// A relay that just (re)started knows nothing of this worker until it
|
|
157
|
+
// heartbeats; the composer's steering snapshot rides that beat.
|
|
158
|
+
onServerHello: () => heartbeat.requestPulse(),
|
|
124
159
|
getSessionId: () => sdkSessionId,
|
|
125
160
|
getPid: () => process.pid,
|
|
126
161
|
onDeliver: async (pending, reason) => {
|
|
@@ -154,20 +189,30 @@ async function main() {
|
|
|
154
189
|
dbg('unknown worker control ignored', type || '(none)');
|
|
155
190
|
},
|
|
156
191
|
});
|
|
192
|
+
linkBridge.attach(wsLink);
|
|
157
193
|
|
|
158
|
-
|
|
194
|
+
let shuttingDown = false;
|
|
195
|
+
const shutdown = async (signal) => {
|
|
196
|
+
if (shuttingDown) return;
|
|
197
|
+
shuttingDown = true;
|
|
159
198
|
dbg(`shutting down (${signal})`);
|
|
160
199
|
try { wsLink.stop(); } catch {}
|
|
161
200
|
try { heartbeat.stopHeartbeat(); } catch {}
|
|
162
201
|
try { controlPoller.stop(); } catch {}
|
|
202
|
+
// Consumed steers still settling must fail, not be left for dead-worker
|
|
203
|
+
// recovery to requeue and run twice (same 2 s cap as the crash guard).
|
|
204
|
+
try {
|
|
205
|
+
const failed = await failSettlingRowsOnShutdown({ api, runner: turnRunner, timeoutMs: 2_000 });
|
|
206
|
+
if (failed) dbg(`failed ${failed} settling row(s) before exit`);
|
|
207
|
+
} catch {}
|
|
163
208
|
// Hard-close the persistent CLI process; a worker teardown cannot save
|
|
164
209
|
// its background tasks, and the orphan-notification replay on the next
|
|
165
210
|
// resume reports whatever they left behind.
|
|
166
211
|
try { turnRunner.shutdown({ graceful: false }); } catch {}
|
|
167
212
|
process.exit(0);
|
|
168
213
|
};
|
|
169
|
-
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
170
|
-
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
214
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
|
215
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
171
216
|
installWorkerCrashGuard({
|
|
172
217
|
api,
|
|
173
218
|
workerName: 'claude-session-worker',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
3
3
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
4
|
+
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
4
5
|
|
|
5
6
|
const PLAN_BOARD_ACTIONS = [
|
|
6
7
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -8,6 +9,26 @@ const PLAN_BOARD_ACTIONS = [
|
|
|
8
9
|
{ id: 'exit_only', label: 'Stop here', mode: 'agent' },
|
|
9
10
|
];
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* The attempt-fencing echo for a row's write bodies. Every publish that names
|
|
14
|
+
* a `messageId` also names the attempt it belongs to, so the relay can refuse
|
|
15
|
+
* writes from a superseded attempt (a requeued row re-delivered elsewhere).
|
|
16
|
+
* Omitted entirely when the row carries no attempt id (a pre-fencing relay).
|
|
17
|
+
*/
|
|
18
|
+
export function attemptFields(message) {
|
|
19
|
+
return message?.attemptId ? { attemptId: message.attemptId } : {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A 409 whose detail names `stale_attempt`: this attempt was superseded. */
|
|
23
|
+
export function isStaleAttemptError(error) {
|
|
24
|
+
if (Number(error?.status) !== 409) return false;
|
|
25
|
+
const detail = typeof error?.detail === 'string' ? error.detail : JSON.stringify(error?.detail || '');
|
|
26
|
+
return detail.includes('stale_attempt');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { buildSteerSettleFailure, steerSettleFailureText };
|
|
30
|
+
export const STEER_SETTLE_FAILED_TEXT = steerSettleFailureText('folded');
|
|
31
|
+
|
|
11
32
|
export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}) {
|
|
12
33
|
const summary = String(planText || '').trim();
|
|
13
34
|
if (!summary) return null;
|
|
@@ -38,6 +59,11 @@ export function buildClaudePlanReadyBoardPayload({ message, planText = '' } = {}
|
|
|
38
59
|
* (delivered or background-continuation) through one publisher.
|
|
39
60
|
*/
|
|
40
61
|
export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRuns = null } = {}) {
|
|
62
|
+
function drainWorkflowRuns({ terminal = false } = {}) {
|
|
63
|
+
if (terminal || typeof takeWorkflowRuns !== 'function') return null;
|
|
64
|
+
return takeWorkflowRuns();
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
async function postActivity(message, text, subagentRunId = null, metadata = null) {
|
|
42
68
|
if (!text) return;
|
|
43
69
|
await api('POST', '/api/activity', {
|
|
@@ -47,6 +73,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
47
73
|
text,
|
|
48
74
|
...(subagentRunId ? { subagentRunId } : {}),
|
|
49
75
|
...(metadata && typeof metadata === 'object' ? { metadata } : {}),
|
|
76
|
+
...attemptFields(message),
|
|
50
77
|
}).catch(() => {});
|
|
51
78
|
}
|
|
52
79
|
|
|
@@ -69,6 +96,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
69
96
|
text: payload.text,
|
|
70
97
|
done: payload.done === true,
|
|
71
98
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
99
|
+
...attemptFields(message),
|
|
72
100
|
}).catch(() => {});
|
|
73
101
|
return;
|
|
74
102
|
}
|
|
@@ -81,6 +109,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
81
109
|
text: payload.text,
|
|
82
110
|
done: payload.done === true,
|
|
83
111
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
112
|
+
...attemptFields(message),
|
|
84
113
|
}).catch(() => {});
|
|
85
114
|
return;
|
|
86
115
|
}
|
|
@@ -96,6 +125,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
96
125
|
parentSubagentId: payload.parentSubagentId || undefined,
|
|
97
126
|
displayName: payload.displayName || undefined,
|
|
98
127
|
status: payload.status,
|
|
128
|
+
...attemptFields(message),
|
|
99
129
|
}).catch(() => {});
|
|
100
130
|
return;
|
|
101
131
|
}
|
|
@@ -155,31 +185,90 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
155
185
|
mode: message.relayMode || 'agent',
|
|
156
186
|
text: String(text || ''),
|
|
157
187
|
done: true,
|
|
188
|
+
...attemptFields(message),
|
|
158
189
|
}).catch(() => {});
|
|
159
190
|
}
|
|
160
191
|
|
|
161
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Returns the outcome: 'published' (the relay took it), 'stale' (another
|
|
194
|
+
* attempt owns the row), 'failed' (not taken and NOT requeued), or
|
|
195
|
+
* 'requeued'. A `kind` marks a row whose prompt the CLI already consumed —
|
|
196
|
+
* 'absorbed' (the reply continues through the next, steered message),
|
|
197
|
+
* 'folded' (answered by the turn it was steered into) or 'stopped' (steered
|
|
198
|
+
* into a turn the user stopped) — and such a row is never
|
|
199
|
+
* requeued, since re-delivery would run the prompt a second time; the
|
|
200
|
+
* caller owns retrying it. `requeueOnFailure: false` opts any other publish
|
|
201
|
+
* into the same contract.
|
|
202
|
+
*/
|
|
203
|
+
async function publishResponse(message, {
|
|
204
|
+
text,
|
|
205
|
+
model,
|
|
206
|
+
terminalError = null,
|
|
207
|
+
modelOrigin,
|
|
208
|
+
absorbed = false,
|
|
209
|
+
kind = null,
|
|
210
|
+
requeueOnFailure = true,
|
|
211
|
+
// Ids of steered queue rows pushed into the turn this response finishes
|
|
212
|
+
// and still unsettled: the relay marks them consumed in the same
|
|
213
|
+
// transaction, so no recovery path ever re-runs them (at-most-once even
|
|
214
|
+
// if this worker dies before settling them).
|
|
215
|
+
consumedSteerIds = null,
|
|
216
|
+
// A caller retrying one publish drains the settled workflow digests ONCE
|
|
217
|
+
// and passes them to every attempt, so a failed attempt cannot lose them.
|
|
218
|
+
workflowRuns: presetWorkflowRuns,
|
|
219
|
+
}) {
|
|
220
|
+
const responseKind = kind || (absorbed ? 'absorbed' : null);
|
|
162
221
|
// Final digests of workflows that settled since the last response ride the
|
|
163
222
|
// next successful response publish — the summarizing turn is the natural
|
|
164
223
|
// transcript anchor for the "Finished background task" card. Terminal
|
|
165
224
|
// failures skip the drain so the digests stay buffered for a later turn;
|
|
166
225
|
// if this POST fails (→ requeue) or the process dies before any response
|
|
167
226
|
// publishes, the drained digests are lost — acceptable v1.
|
|
168
|
-
const workflowRuns =
|
|
169
|
-
?
|
|
170
|
-
:
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
227
|
+
const workflowRuns = presetWorkflowRuns !== undefined
|
|
228
|
+
? presetWorkflowRuns
|
|
229
|
+
: drainWorkflowRuns({ terminal: Boolean(terminalError) });
|
|
230
|
+
// Entries are { id, attemptId } (the relay fences each mark on its
|
|
231
|
+
// attempt) or bare ids.
|
|
232
|
+
const consumed = (Array.isArray(consumedSteerIds) ? consumedSteerIds : [])
|
|
233
|
+
.map((entry) => (entry && typeof entry === 'object'
|
|
234
|
+
? { id: String(entry.id || '').trim(), attemptId: entry.attemptId || null }
|
|
235
|
+
: { id: String(entry || '').trim(), attemptId: null }))
|
|
236
|
+
.filter((entry) => entry.id);
|
|
237
|
+
try {
|
|
238
|
+
await api('POST', '/api/response', {
|
|
239
|
+
messageId: message.id,
|
|
240
|
+
conversationId: message.conversationId,
|
|
241
|
+
text: String(text || ''),
|
|
242
|
+
model: model || null,
|
|
243
|
+
modelOrigin: modelOrigin
|
|
244
|
+
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
245
|
+
...(Array.isArray(workflowRuns) && workflowRuns.length ? { workflowRuns } : {}),
|
|
246
|
+
...(terminalError ? { terminalError } : {}),
|
|
247
|
+
// The relay stamps the assistant message's kind from these: 'absorbed'
|
|
248
|
+
// renders as merged into the next (steered) user message, 'folded' as
|
|
249
|
+
// a compact marker on its own message, 'stopped' as a steer the
|
|
250
|
+
// user's Stop cut off unanswered.
|
|
251
|
+
...(responseKind === 'absorbed' ? { absorbed: true } : {}),
|
|
252
|
+
...(responseKind && responseKind !== 'absorbed' ? { kind: responseKind } : {}),
|
|
253
|
+
...(consumed.length ? { consumedSteerIds: consumed } : {}),
|
|
254
|
+
...attemptFields(message),
|
|
255
|
+
});
|
|
256
|
+
return 'published';
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// A stale_attempt 409 means the row already moved on to another attempt
|
|
259
|
+
// (requeued and re-delivered); the requeue would 409 the same way, and
|
|
260
|
+
// the newer attempt owes the row its answer — drop this one.
|
|
261
|
+
if (isStaleAttemptError(error)) {
|
|
262
|
+
dbg('response refused as stale_attempt; dropping', message.id);
|
|
263
|
+
return 'stale';
|
|
264
|
+
}
|
|
265
|
+
if (responseKind || !requeueOnFailure) {
|
|
266
|
+
dbg('response publish failed; not requeuing a consumed prompt', message.id, error?.message || String(error));
|
|
267
|
+
return 'failed';
|
|
268
|
+
}
|
|
269
|
+
await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch(() => {});
|
|
270
|
+
return 'requeued';
|
|
271
|
+
}
|
|
183
272
|
}
|
|
184
273
|
|
|
185
274
|
/**
|
|
@@ -214,7 +303,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
214
303
|
* plan-mode fallback board, response row. Mirrors the pre-session-process
|
|
215
304
|
* turn runner's tail exactly.
|
|
216
305
|
*/
|
|
217
|
-
async function publishCompletedTurn({ message, state, responseModel, planBoardPosted }) {
|
|
306
|
+
async function publishCompletedTurn({ message, state, responseModel, planBoardPosted, consumedSteerIds = null }) {
|
|
218
307
|
const result = state.result;
|
|
219
308
|
const finalText = String(
|
|
220
309
|
state.resultTexts.join('\n\n') || result?.text || state.lastStreamedText || '',
|
|
@@ -236,10 +325,10 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
236
325
|
// fails the message with a misleading "Relay timeout".
|
|
237
326
|
const publishedText = finalText || EMPTY_TURN_COMPLETION_NOTE;
|
|
238
327
|
await publishFinalStream(message, publishedText);
|
|
239
|
-
await publishResponse(message, { text: publishedText, model: responseModel });
|
|
328
|
+
await publishResponse(message, { text: publishedText, model: responseModel, consumedSteerIds });
|
|
240
329
|
}
|
|
241
330
|
|
|
242
|
-
async function publishErrorResult({ message, state, responseModel }) {
|
|
331
|
+
async function publishErrorResult({ message, state, responseModel, consumedSteerIds = null }) {
|
|
243
332
|
const result = state.result;
|
|
244
333
|
const errorText = result.text
|
|
245
334
|
|| `Claude turn failed (${result.subtype || 'unknown error'}).`;
|
|
@@ -255,6 +344,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
255
344
|
failedAt: new Date().toISOString(),
|
|
256
345
|
queueMessageId: String(message.id || '') || null,
|
|
257
346
|
},
|
|
347
|
+
consumedSteerIds,
|
|
258
348
|
});
|
|
259
349
|
}
|
|
260
350
|
|
|
@@ -278,6 +368,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
278
368
|
|
|
279
369
|
return {
|
|
280
370
|
postActivity,
|
|
371
|
+
drainWorkflowRuns,
|
|
281
372
|
dispatchAction,
|
|
282
373
|
publishContextUsage,
|
|
283
374
|
publishPlanUsage,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The Claude worker's glue between its session runner and the relay socket
|
|
2
|
+
// link. The implementation moved to shared/worker-runtime/runner-link-wiring.mjs
|
|
3
|
+
// when the Copilot SDK worker adopted the same delivery gate; this module is
|
|
4
|
+
// the import path the Claude worker and its tests still use.
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
createRunnerLinkBridge,
|
|
8
|
+
failSettlingRowsOnShutdown,
|
|
9
|
+
} from '../../shared/worker-runtime/runner-link-wiring.mjs';
|
|
@@ -91,7 +91,11 @@ export function buildInstalledCopilotClientOptions({
|
|
|
91
91
|
path: cliPath,
|
|
92
92
|
},
|
|
93
93
|
mode: 'empty',
|
|
94
|
-
|
|
94
|
+
// Runtime 1.0.83 made 'empty' mode refuse to construct without an explicit
|
|
95
|
+
// per-session persistence location. ~/.copilot is exactly what the runtime
|
|
96
|
+
// defaulted to before (baseDirectory sets COPILOT_HOME), so the import
|
|
97
|
+
// sweep keeps seeing the CLI's real session state.
|
|
98
|
+
baseDirectory: String(baseDirectory || '').trim() || path.join(os.homedir(), '.copilot'),
|
|
95
99
|
useLoggedInUser: true,
|
|
96
100
|
logLevel,
|
|
97
101
|
workingDirectory: cwd,
|
|
@@ -119,8 +123,13 @@ export async function createInstalledCopilotClient({
|
|
|
119
123
|
approveAll: sdk.approveAll,
|
|
120
124
|
paths,
|
|
121
125
|
async dispose() {
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
// stop() can reject (e.g. the CLI already died); dispose() must still
|
|
127
|
+
// run or the transport resources leak with it.
|
|
128
|
+
try {
|
|
129
|
+
await client.stop?.();
|
|
130
|
+
} finally {
|
|
131
|
+
await client.dispose?.();
|
|
132
|
+
}
|
|
124
133
|
},
|
|
125
134
|
};
|
|
126
135
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The relay's own `ask_user` tool for Copilot SDK sessions.
|
|
2
|
+
//
|
|
3
|
+
// The runtime's built-in `ask_user` offers the model only `question` and
|
|
4
|
+
// `choices` (captured from the request body, runtime 1.0.88), so a model has
|
|
5
|
+
// no way to say "the user may pick several of these" — and the relay could
|
|
6
|
+
// only guess from the wording. The SDK lets a host register a tool that
|
|
7
|
+
// overrides a built-in one of the same name (`overridesBuiltInTool`), so the
|
|
8
|
+
// worker registers this superset instead: the same name and fields every
|
|
9
|
+
// model already knows, plus `multi_select`. Verified with the fake-provider
|
|
10
|
+
// probe (2026-09-25): the model sees exactly one `ask_user` with this schema,
|
|
11
|
+
// the handler receives the call, and the built-in path is never used.
|
|
12
|
+
//
|
|
13
|
+
// Pure: schema, argument parsing and the result wording. The session process
|
|
14
|
+
// wires the handler to the relay question bridge.
|
|
15
|
+
|
|
16
|
+
import { normalizeUserInputChoices } from './copilot-question-bridge.mjs';
|
|
17
|
+
|
|
18
|
+
export const RELAY_ASK_USER_TOOL_NAME = 'ask_user';
|
|
19
|
+
|
|
20
|
+
export const RELAY_ASK_USER_TOOL_DESCRIPTION = [
|
|
21
|
+
'Ask the user a question and wait for their response.',
|
|
22
|
+
'Use this tool when you need to ask the user questions during execution. This allows you to:',
|
|
23
|
+
'1. Gather user preferences or requirements',
|
|
24
|
+
'2. Clarify ambiguous instructions',
|
|
25
|
+
'3. Get decisions on implementation choices as you work',
|
|
26
|
+
'4. Offer choices to the user about what direction to take',
|
|
27
|
+
'The question is shown to the user as a card with a button per choice.',
|
|
28
|
+
'Set multi_select to true when the user may pick several of the choices (the choices are not',
|
|
29
|
+
'mutually exclusive); the answer then lists every pick, separated by ", ". Leave it false or',
|
|
30
|
+
'omit it when exactly one choice applies.',
|
|
31
|
+
].join('\n');
|
|
32
|
+
|
|
33
|
+
export const RELAY_ASK_USER_TOOL_PARAMETERS = Object.freeze({
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
question: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'The question to ask the user. Ensure only one question is asked at a time - do not bundle multiple questions together.',
|
|
39
|
+
},
|
|
40
|
+
choices: {
|
|
41
|
+
type: 'array',
|
|
42
|
+
items: { type: 'string' },
|
|
43
|
+
description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible.',
|
|
44
|
+
},
|
|
45
|
+
multi_select: {
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
description: 'true when the user may pick several of the choices; false or omitted when exactly one applies.',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: ['question'],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** The call's arguments, tolerating the camelCase spelling some models emit. */
|
|
54
|
+
export function parseAskUserToolArguments(args) {
|
|
55
|
+
const source = args && typeof args === 'object' && !Array.isArray(args) ? args : {};
|
|
56
|
+
return {
|
|
57
|
+
question: String(source.question || '').trim(),
|
|
58
|
+
choices: normalizeUserInputChoices(source.choices),
|
|
59
|
+
multiSelect: source.multi_select === true || source.multiSelect === true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The tool result the model reads, worded like the built-in's ("User
|
|
65
|
+
* selected: b" for a choice, "User responded: …" for anything else) so a
|
|
66
|
+
* model's habits carry over. A timed-out card returns the bridge's
|
|
67
|
+
* continuation text as it is.
|
|
68
|
+
*/
|
|
69
|
+
export function formatAskUserToolResult({ answer, wasFreeform = true, timedOut = false } = {}) {
|
|
70
|
+
const text = String(answer ?? '').trim();
|
|
71
|
+
if (timedOut) return text;
|
|
72
|
+
if (!text) return 'User responded with an empty answer.';
|
|
73
|
+
// `wasFreeform` comes from the question bridge (`deriveWasFreeform`), the
|
|
74
|
+
// single owner of "was this one of the offered choices".
|
|
75
|
+
return wasFreeform === false ? `User selected: ${text}` : `User responded: ${text}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether a session create/resume failure is the runtime refusing the
|
|
80
|
+
* override (an older runtime without `overridesBuiltInTool`, or one that
|
|
81
|
+
* rejects replacing this built-in). Anything else is a real failure.
|
|
82
|
+
*/
|
|
83
|
+
export function isToolOverrideRejection(error) {
|
|
84
|
+
const message = String(error?.message || error || '');
|
|
85
|
+
if (/overridesBuiltInTool/i.test(message)) return true;
|
|
86
|
+
// The tool named together with an override/clash complaint — a message that
|
|
87
|
+
// merely mentions ask_user (a replayed call in history, say) is not one.
|
|
88
|
+
return /\bask_user\b/i.test(message) && /(built-?in|clash|conflict|overrid|duplicate)/i.test(message);
|
|
89
|
+
}
|
|
@@ -9,7 +9,24 @@ function isImageAttachment(att) {
|
|
|
9
9
|
return String(att?.type || '').toLowerCase().startsWith('image/');
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** Strict base64: the alphabet, optional padding, block length a multiple of 4. */
|
|
13
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Decode-and-validate an inline data URL (audit #33).
|
|
17
|
+
*
|
|
18
|
+
* Disk-backed images have always enforced the decoded 5 MiB ceiling; data URLs
|
|
19
|
+
* used to skip it entirely, so a ~9 MiB decoded image (the server's ENCODED
|
|
20
|
+
* ceiling is larger) could ride straight past a limit this module claims to
|
|
21
|
+
* enforce. The ceiling is now applied to the DECODED byte count, uniformly with
|
|
22
|
+
* the disk path, and the base64 itself is validated first — `Buffer.from` is
|
|
23
|
+
* lenient and would silently decode garbage into a corrupt blob.
|
|
24
|
+
*
|
|
25
|
+
* Returns `{ data, mimeType }` for an embeddable image, `{ tooLarge: true }`
|
|
26
|
+
* for a valid image over the ceiling (so the caller can degrade explicitly),
|
|
27
|
+
* and `null` for anything invalid.
|
|
28
|
+
*/
|
|
29
|
+
function imageFromDataUrl(att, maxBytes) {
|
|
13
30
|
const dataUrl = String(att?.dataUrl || '').trim();
|
|
14
31
|
if (!dataUrl.startsWith('data:')) return null;
|
|
15
32
|
const match = dataUrl.match(/^data:([^;,]+);base64,(.+)$/i);
|
|
@@ -17,6 +34,8 @@ function imageFromDataUrl(att) {
|
|
|
17
34
|
const mimeType = String(match[1] || '').trim().toLowerCase();
|
|
18
35
|
const data = String(match[2] || '').trim();
|
|
19
36
|
if (!mimeType.startsWith('image/') || !data) return null;
|
|
37
|
+
if (data.length % 4 !== 0 || !BASE64_RE.test(data)) return null;
|
|
38
|
+
if (Buffer.from(data, 'base64').length > maxBytes) return { tooLarge: true };
|
|
20
39
|
return { data, mimeType };
|
|
21
40
|
}
|
|
22
41
|
|
|
@@ -59,6 +78,7 @@ export function buildCopilotMessageOptions(message, {
|
|
|
59
78
|
|
|
60
79
|
if (isImageAttachment(att)) {
|
|
61
80
|
let blob = null;
|
|
81
|
+
let tooLarge = false;
|
|
62
82
|
if (filePath && fsImpl.existsSync(filePath)) {
|
|
63
83
|
try {
|
|
64
84
|
const bytes = fsImpl.readFileSync(filePath);
|
|
@@ -72,13 +92,26 @@ export function buildCopilotMessageOptions(message, {
|
|
|
72
92
|
blob = null;
|
|
73
93
|
}
|
|
74
94
|
}
|
|
75
|
-
if (!blob)
|
|
95
|
+
if (!blob) {
|
|
96
|
+
// The decoded ceiling applies to data URLs exactly as to disk reads —
|
|
97
|
+
// this is also the path an OVERSIZED disk image with a dataUrl twin
|
|
98
|
+
// used to sneak through.
|
|
99
|
+
const decoded = imageFromDataUrl(att, maxInlineImageBytes);
|
|
100
|
+
if (decoded?.tooLarge) tooLarge = true;
|
|
101
|
+
else blob = decoded;
|
|
102
|
+
}
|
|
76
103
|
if (blob) {
|
|
77
104
|
attachments.push({ type: 'blob', data: blob.data, mimeType: blob.mimeType, displayName: name });
|
|
78
105
|
noteLines.push(`Attached image "${name}" (${mime}) is embedded in this message.`);
|
|
79
106
|
} else if (filePath) {
|
|
80
107
|
attachments.push({ type: 'file', path: filePath, displayName: name });
|
|
81
108
|
noteLines.push(`Attached image "${name}" (${mime}): ${filePath}`);
|
|
109
|
+
} else if (tooLarge) {
|
|
110
|
+
// A valid image with nowhere to degrade to: too big to embed, no path
|
|
111
|
+
// for a file reference. Dropping it SILENTLY would leave the model
|
|
112
|
+
// answering about an image it never saw, so the omission is stated in
|
|
113
|
+
// the same note channel every other attachment uses.
|
|
114
|
+
noteLines.push(`Attached image "${name}" (${mime}) was too large to embed and has no file path, so it was omitted.`);
|
|
82
115
|
}
|
|
83
116
|
continue;
|
|
84
117
|
}
|
|
@@ -54,6 +54,51 @@ export const SHELL_SETTLED_NOTIFICATION_KINDS = Object.freeze([
|
|
|
54
54
|
/** Tools that address an already-running shell by id. */
|
|
55
55
|
export const SHELL_HANDLE_TOOLS = Object.freeze(['read_bash', 'write_bash', 'stop_bash']);
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* `system.notification` kinds that mean "a background agent this session
|
|
59
|
+
* started is done" — `agent_idle` for a multi-turn agent that finished its
|
|
60
|
+
* prompt and now waits for messages, `agent_completed` for a single-shot one.
|
|
61
|
+
* Both make the runtime re-invoke the model (`read_agent` + a reply under the
|
|
62
|
+
* spawning interaction), so both herald a continuation exactly like the shell
|
|
63
|
+
* kinds do (live-probed on runtime 1.0.88, 2026-09-25).
|
|
64
|
+
*/
|
|
65
|
+
export const AGENT_SETTLED_NOTIFICATION_KINDS = Object.freeze([
|
|
66
|
+
'agent_completed',
|
|
67
|
+
'agent_idle',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The background agent a `system.notification` reports as settled, or null.
|
|
72
|
+
* `{ agentId, agentType, displayName, description, status }` — `status` is
|
|
73
|
+
* 'completed' | 'failed' for `agent_completed`, 'idle' for `agent_idle`.
|
|
74
|
+
*/
|
|
75
|
+
export function settledAgentFromNotification(event) {
|
|
76
|
+
if (String(event?.type || '') !== 'system.notification') return null;
|
|
77
|
+
const data = eventData(event);
|
|
78
|
+
const kind = data.kind && typeof data.kind === 'object' ? data.kind : {};
|
|
79
|
+
const type = String(kind.type || '');
|
|
80
|
+
if (!AGENT_SETTLED_NOTIFICATION_KINDS.includes(type)) return null;
|
|
81
|
+
const agentId = String(kind.agentId || '').trim();
|
|
82
|
+
if (!agentId) return null;
|
|
83
|
+
return {
|
|
84
|
+
agentId,
|
|
85
|
+
agentType: String(kind.agentType || '').trim(),
|
|
86
|
+
displayName: String(kind.displayName || '').trim(),
|
|
87
|
+
description: String(kind.description || '').trim(),
|
|
88
|
+
status: type === 'agent_idle' ? 'idle' : (String(kind.status || '').trim() || 'completed'),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A one-line transcript note for a settled background agent, posted into its continuation. */
|
|
93
|
+
export function describeSettledAgent(agent) {
|
|
94
|
+
if (!agent?.agentId) return '';
|
|
95
|
+
const name = agent.displayName || agent.description || agent.agentType || 'agent';
|
|
96
|
+
const verb = agent.status === 'failed' ? 'failed' : 'finished';
|
|
97
|
+
return agent.description && agent.description !== name
|
|
98
|
+
? `Background agent ${name} ${verb}: ${agent.description}`
|
|
99
|
+
: `Background agent ${name} ${verb}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
57
102
|
// `<command started in detached background with shellId: 1>`
|
|
58
103
|
const SHELL_OPENED_RE = /detached\s+(?:background|command)[^<>]*?shellId:\s*([^\s>,]+)/i;
|
|
59
104
|
// `<detached command with shellId: 1 completed with exit code 0>`
|
|
@@ -118,7 +163,17 @@ export const CONTINUATION_OPENING_EVENT_TYPES = Object.freeze([
|
|
|
118
163
|
|
|
119
164
|
const OPENING_TYPES = new Set(CONTINUATION_OPENING_EVENT_TYPES);
|
|
120
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Only ROOT-agent events open a turn. A background subagent keeps emitting
|
|
168
|
+
* `agentId`-tagged `tool.execution_start` / `assistant.message` / its own
|
|
169
|
+
* `user.message` after the turn that spawned it has settled (live-probed on
|
|
170
|
+
* runtime 1.0.88: the subagent's whole life happens under the main loop's
|
|
171
|
+
* idle), and none of that is the main agent starting work. The root agent's
|
|
172
|
+
* follow-up when the subagent finishes (`read_agent` + a reply) carries no
|
|
173
|
+
* `agentId` and opens the continuation as before.
|
|
174
|
+
*/
|
|
121
175
|
export function isContinuationOpeningEvent(event) {
|
|
176
|
+
if (String(event?.agentId || '').trim()) return false;
|
|
122
177
|
return OPENING_TYPES.has(String(event?.type || ''));
|
|
123
178
|
}
|
|
124
179
|
|