@oar-sh/oar 0.9.13 → 0.9.15
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 +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +396 -0
- package/README.md +12 -12
- package/bin/oar.js +677 -103
- package/package.json +7 -5
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +39 -13
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/public/app/api-client.js +3 -1
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/index.html +9 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +14 -11
- package/server/repositories/message-repository.mjs +29 -28
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +65 -10
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/sessions-routes.mjs +49 -16
- package/server/server-runtime.mjs +156 -94
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/oar-cli-helpers.mjs +18 -5
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/push-dispatch-service.mjs +33 -6
- package/server/services/relay-event-service.mjs +183 -5
- package/server/services/relay-restart-queue.mjs +10 -3
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +30 -9
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/windows-autostart-service.mjs +415 -45
- package/server/services/windows-boot-autostart-service.mjs +962 -185
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +54 -4
- package/shared/remote-relay-contract.mjs +20 -10
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2187
- package/server/uploads/image_input.md +0 -540
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oar-sh/oar",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.15",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,23 +22,25 @@
|
|
|
22
22
|
"!server/**/*.test.mjs",
|
|
23
23
|
"!server/**/*-test-harness.mjs",
|
|
24
24
|
"!server/**/fixtures/",
|
|
25
|
+
"!server/README.md",
|
|
25
26
|
"!server/data/",
|
|
26
27
|
"!server/logs/",
|
|
27
28
|
"!server/config.json",
|
|
28
29
|
"!server/uploads/",
|
|
29
30
|
"server/uploads/README.md",
|
|
30
|
-
"server/uploads/image_input.md",
|
|
31
31
|
"shared/",
|
|
32
32
|
"!shared/**/*.test.mjs",
|
|
33
33
|
"!shared/**/*-test-harness.mjs",
|
|
34
34
|
"!shared/**/fixtures/",
|
|
35
35
|
".github/extensions/web-relay/",
|
|
36
36
|
"!.github/extensions/web-relay/**/*.test.mjs",
|
|
37
|
+
"!.github/extensions/web-relay/README.md",
|
|
37
38
|
"README.md",
|
|
38
39
|
"CHANGELOG.md",
|
|
39
40
|
"LICENSE"
|
|
40
41
|
],
|
|
41
42
|
"scripts": {
|
|
43
|
+
"preinstall": "node server/services/package-data-rescue-preinstall.mjs",
|
|
42
44
|
"start": "node server/server.js",
|
|
43
45
|
"copilot:relay": "gh copilot -- --allow-all",
|
|
44
46
|
"test": "node --import ./scripts/node-test-keep-alive.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
|
|
@@ -50,9 +52,9 @@
|
|
|
50
52
|
"test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
|
|
51
53
|
},
|
|
52
54
|
"dependencies": {
|
|
53
|
-
"@anthropic-ai/claude-agent-sdk": "0.3.
|
|
54
|
-
"@cursor/sdk": "1.0.
|
|
55
|
-
"@github/copilot-sdk": "1.0.
|
|
55
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.304",
|
|
56
|
+
"@cursor/sdk": "1.0.38",
|
|
57
|
+
"@github/copilot-sdk": "1.0.23",
|
|
56
58
|
"better-sqlite3": "12.11.1",
|
|
57
59
|
"express": "4.22.3",
|
|
58
60
|
"qrcode-terminal": "0.12.0",
|
|
@@ -4,6 +4,7 @@ import fs from 'node:fs';
|
|
|
4
4
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
5
5
|
import { createRelayQuestion } from '../../shared/question-wait.mjs';
|
|
6
6
|
import { DEFAULT_QUESTION_TIMEOUT_MS } from '../../shared/question-timeout.mjs';
|
|
7
|
+
import { readApprovalAnswer } from '../../shared/question-answer-rule.mjs';
|
|
7
8
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
8
9
|
import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
|
|
9
10
|
import { resolveDeliveredAttribution, sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
|
|
@@ -27,6 +28,7 @@ const PERMISSION_ALLOW = 'Allow';
|
|
|
27
28
|
const PERMISSION_DENY = 'Deny';
|
|
28
29
|
const QUESTION_UNANSWERED_MESSAGE = 'The user did not answer. Continue without this, or end the turn and say what you need.';
|
|
29
30
|
const PERMISSION_DENIED_MESSAGE = 'The user denied this tool use.';
|
|
31
|
+
const PERMISSION_UNREADABLE_MESSAGE = 'The permission card was answered with neither Allow nor Deny, so the tool use is not allowed.';
|
|
30
32
|
// A catch-up after a worker restart reads the events of one unfinished turn;
|
|
31
33
|
// the cap only stops a cursor that never advances.
|
|
32
34
|
const MAX_CATCH_UP_PAGES = 200;
|
|
@@ -253,8 +255,8 @@ export function createClaudeCloudSessionRunner({
|
|
|
253
255
|
const requested = String(message?.model || '').trim();
|
|
254
256
|
const perTurn = requested && requested.toLowerCase() !== 'auto' ? requested : '';
|
|
255
257
|
// The delivered message names the conversation's current model; the launch
|
|
256
|
-
// model is what it was when the worker started
|
|
257
|
-
//
|
|
258
|
+
// model is what it was when the worker started. A change after the first
|
|
259
|
+
// message is applied before the next one (applyModel).
|
|
258
260
|
return stripModelTierSuffix(String(message?.providerModel || '').trim() || perTurn || defaultModel);
|
|
259
261
|
}
|
|
260
262
|
|
|
@@ -723,8 +725,12 @@ export function createClaudeCloudSessionRunner({
|
|
|
723
725
|
});
|
|
724
726
|
const result = await askUserBridge.waitForRelayQuestionAnswer(questionId, { signal });
|
|
725
727
|
if (result.timedOut || signal.aborted) return { behavior: 'deny', message: QUESTION_UNANSWERED_MESSAGE };
|
|
726
|
-
|
|
727
|
-
return { behavior: '
|
|
728
|
+
const decision = readApprovalAnswer(result.answer, { approve: PERMISSION_ALLOW, deny: PERMISSION_DENY });
|
|
729
|
+
if (decision === 'approve') return { behavior: 'allow', updatedInput: request.input };
|
|
730
|
+
if (decision === 'deny') return { behavior: 'deny', message: PERMISSION_DENIED_MESSAGE };
|
|
731
|
+
// The relay takes only the choices on this card; anything else is no
|
|
732
|
+
// decision of the user's, and the agent is told so.
|
|
733
|
+
return { behavior: 'deny', message: PERMISSION_UNREADABLE_MESSAGE };
|
|
728
734
|
}
|
|
729
735
|
|
|
730
736
|
async function askUser(t, request, signal) {
|
|
@@ -39,11 +39,13 @@ import {
|
|
|
39
39
|
isStaleAttemptError,
|
|
40
40
|
} from './claude-turn-publisher.mjs';
|
|
41
41
|
import { createSubagentTimelineRecorder } from './subagent-timeline-recorder.mjs';
|
|
42
|
+
import { SUBAGENT_TASK_TYPES } from '../../shared/subagent-timeline.mjs';
|
|
42
43
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
43
44
|
import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
|
|
44
45
|
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
45
46
|
import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
46
47
|
import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
|
|
48
|
+
import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
47
49
|
import {
|
|
48
50
|
ANSWERED_ELSEWHERE_KIND,
|
|
49
51
|
DELIVERY_ANSWERED_ELSEWHERE_TEXT,
|
|
@@ -82,8 +84,6 @@ function modeAppendClass(relayMode) {
|
|
|
82
84
|
|
|
83
85
|
export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
|
|
84
86
|
|
|
85
|
-
/** Task types that run a model of their own (vs. bash/monitor/workflow). */
|
|
86
|
-
const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
|
|
87
87
|
const MAX_TASK_TOOL_CALL_CHARS = 300;
|
|
88
88
|
/** Output paths remembered per process (one per backgrounded shell). */
|
|
89
89
|
const MAX_TASK_OUTPUT_PATHS = 200;
|
|
@@ -459,6 +459,9 @@ export function createClaudeSessionRunner({
|
|
|
459
459
|
readContextUsageImpl = readContextUsage,
|
|
460
460
|
readPlanUsageImpl = readPlanUsage,
|
|
461
461
|
relocateTranscriptImpl = relocateClaudeTranscriptForCwd,
|
|
462
|
+
// The error a spawn in `cwd` fails with up front, or null
|
|
463
|
+
// (shared/worker-runtime/working-directory.mjs).
|
|
464
|
+
checkWorkingDirectoryImpl = missingWorkingDirectoryError,
|
|
462
465
|
idleShutdownMs = 10 * 60_000,
|
|
463
466
|
getBackgroundTaskTimeoutMs = () => 0,
|
|
464
467
|
// The per-conversation auto-compact window (token count, null = Auto). Read
|
|
@@ -1763,7 +1766,7 @@ export function createClaudeSessionRunner({
|
|
|
1763
1766
|
// agent-like task runs on the session model. Resolved at publish
|
|
1764
1767
|
// time so the spawn block landing after task_started still heals on
|
|
1765
1768
|
// the next publish. Bash & co. get no model at all.
|
|
1766
|
-
const agentLike =
|
|
1769
|
+
const agentLike = SUBAGENT_TASK_TYPES.has(String(task.taskType || ''));
|
|
1767
1770
|
const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
|
|
1768
1771
|
const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
|
|
1769
1772
|
const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
|
|
@@ -1777,6 +1780,7 @@ export function createClaudeSessionRunner({
|
|
|
1777
1780
|
lastToolCall: (agentLike && activity.lastToolCall) || null,
|
|
1778
1781
|
totalTokens: task.totalTokens ?? null,
|
|
1779
1782
|
toolUseId: task.toolUseId || null,
|
|
1783
|
+
...(task.taskRunId ? { taskRunId: task.taskRunId } : {}),
|
|
1780
1784
|
subagentType: task.subagentType || null,
|
|
1781
1785
|
model,
|
|
1782
1786
|
modelInherited: Boolean(model && !pinnedModel && !activity.model),
|
|
@@ -2215,7 +2219,7 @@ export function createClaudeSessionRunner({
|
|
|
2215
2219
|
// alias like "opus") and the full input of the tool it is calling now.
|
|
2216
2220
|
// Single-lined and capped: the panel crops it to its width.
|
|
2217
2221
|
function recordSubagentActivity(sdkMessage) {
|
|
2218
|
-
const parentId =
|
|
2222
|
+
const parentId = timelineRecorder.runIdFor(sdkMessage?.parent_tool_use_id);
|
|
2219
2223
|
if (!parentId) return;
|
|
2220
2224
|
const entry = proc.subagentActivity.get(parentId) || {};
|
|
2221
2225
|
const before = `${entry.model || ''}\n${entry.lastToolCall || ''}`;
|
|
@@ -2591,11 +2595,15 @@ export function createClaudeSessionRunner({
|
|
|
2591
2595
|
.filter((task) => String(task?.task_id || '').trim())
|
|
2592
2596
|
.map((task) => {
|
|
2593
2597
|
const taskId = String(task.task_id).trim();
|
|
2594
|
-
const
|
|
2598
|
+
const runId = String(task?.run_id || '').trim();
|
|
2599
|
+
// Another run of a task still listed (resumed at once): its own row.
|
|
2600
|
+
const listed = previous.get(taskId) || {};
|
|
2601
|
+
const known = runId && listed.taskRunId && listed.taskRunId !== runId ? {} : listed;
|
|
2595
2602
|
return [taskId, {
|
|
2596
2603
|
...known,
|
|
2597
2604
|
taskType: String(task?.task_type || '').trim() || known.taskType || '',
|
|
2598
2605
|
description: String(task?.description || '').trim() || known.description || '',
|
|
2606
|
+
taskRunId: runId || known.taskRunId || '',
|
|
2599
2607
|
startedAt: known.startedAt || Date.now(),
|
|
2600
2608
|
}];
|
|
2601
2609
|
}));
|
|
@@ -2651,8 +2659,14 @@ export function createClaudeSessionRunner({
|
|
|
2651
2659
|
known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
|
|
2652
2660
|
// The task's spawning tool_use id is the same identity the subagent
|
|
2653
2661
|
// bubbles key on — recorded so the panel and the bubbles can be
|
|
2654
|
-
// correlated (and a targeted stop can find its task).
|
|
2655
|
-
|
|
2662
|
+
// correlated (and a targeted stop can find its task). A resumed agent
|
|
2663
|
+
// names the call that resumed it (SendMessage); the recorder leads
|
|
2664
|
+
// that back to the agent's run.
|
|
2665
|
+
const toolUseId = String(sdkMessage.tool_use_id || '').trim();
|
|
2666
|
+
known.toolUseId = timelineRecorder.runIdFor(toolUseId) || known.toolUseId || '';
|
|
2667
|
+
// Which run of the task this is: the SDK's run id, else (an older
|
|
2668
|
+
// CLI) the tool use that started it, which differs per run too.
|
|
2669
|
+
known.taskRunId = String(sdkMessage.run_id || '').trim() || known.taskRunId || toolUseId;
|
|
2656
2670
|
// task_started can be the first event carrying the task_type; a
|
|
2657
2671
|
// workflow revealed here must start its poller too.
|
|
2658
2672
|
syncWorkflowPoller(proc);
|
|
@@ -2667,7 +2681,7 @@ export function createClaudeSessionRunner({
|
|
|
2667
2681
|
known.summary = String(sdkMessage.summary || '').trim() || known.summary;
|
|
2668
2682
|
known.lastToolName = String(sdkMessage.last_tool_name || '').trim() || known.lastToolName;
|
|
2669
2683
|
known.subagentType = String(sdkMessage.subagent_type || '').trim() || known.subagentType || '';
|
|
2670
|
-
known.toolUseId =
|
|
2684
|
+
known.toolUseId = timelineRecorder.runIdFor(sdkMessage.tool_use_id) || known.toolUseId || '';
|
|
2671
2685
|
const totalTokens = Number(sdkMessage.usage?.total_tokens);
|
|
2672
2686
|
if (Number.isFinite(totalTokens)) known.totalTokens = totalTokens;
|
|
2673
2687
|
// The sub-agent's tool calls so far: the panel row's step count.
|
|
@@ -2701,13 +2715,15 @@ export function createClaudeSessionRunner({
|
|
|
2701
2715
|
proc.taskCommands.delete(taskId);
|
|
2702
2716
|
const exitCode = shellLike ? exitCodeFromSummary(sdkMessage.summary) : null;
|
|
2703
2717
|
const toolUses = Number(sdkMessage.usage?.tool_uses);
|
|
2704
|
-
const steps =
|
|
2718
|
+
const steps = SUBAGENT_TASK_TYPES.has(String(published?.taskType || '')) && Number.isFinite(toolUses) && toolUses >= 0
|
|
2705
2719
|
? Math.floor(toolUses)
|
|
2706
2720
|
: null;
|
|
2721
|
+
const settledRunId = String(sdkMessage.run_id || published?.taskRunId || '').trim();
|
|
2707
2722
|
void keptTaskSettles.post('/api/background-task-settled', {
|
|
2708
2723
|
conversationId: sdkSessionId,
|
|
2709
2724
|
taskId,
|
|
2710
|
-
toolUseId:
|
|
2725
|
+
toolUseId: timelineRecorder.runIdFor(sdkMessage.tool_use_id) || published?.toolUseId || null,
|
|
2726
|
+
...(settledRunId ? { taskRunId: settledRunId } : {}),
|
|
2711
2727
|
status: String(sdkMessage.status || '').trim() || 'completed',
|
|
2712
2728
|
summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
|
|
2713
2729
|
...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
|
|
@@ -3501,6 +3517,7 @@ export function createClaudeSessionRunner({
|
|
|
3501
3517
|
message: ctx.message,
|
|
3502
3518
|
errorText: String(streamError?.message || streamError || 'unknown error'),
|
|
3503
3519
|
usageLimit: usageLimit.classifyException(streamError),
|
|
3520
|
+
error: streamError,
|
|
3504
3521
|
}).catch(() => {});
|
|
3505
3522
|
}
|
|
3506
3523
|
continue;
|
|
@@ -3522,6 +3539,11 @@ export function createClaudeSessionRunner({
|
|
|
3522
3539
|
// Spawn / adapt
|
|
3523
3540
|
|
|
3524
3541
|
function spawnProcess(message, { resume: resumeOverride = '' } = {}) {
|
|
3542
|
+
// A spawn in a folder that is gone fails with ENOENT, which the SDK
|
|
3543
|
+
// reports as a native binary that does not match the libc. Thrown here,
|
|
3544
|
+
// the delivery fails with the folder's name instead (handlePendingPayload).
|
|
3545
|
+
const missingFolder = checkWorkingDirectoryImpl(cwd);
|
|
3546
|
+
if (missingFolder) throw missingFolder;
|
|
3525
3547
|
const relayMode = message.relayMode || 'agent';
|
|
3526
3548
|
const model = resolvePerTurnModel(message);
|
|
3527
3549
|
const effort = normalizeClaudeEffort(message.reasoningEffort);
|
|
@@ -3742,7 +3764,11 @@ export function createClaudeSessionRunner({
|
|
|
3742
3764
|
syncDeliveryReadiness();
|
|
3743
3765
|
}
|
|
3744
3766
|
} catch (error) {
|
|
3745
|
-
|
|
3767
|
+
// A launch that failed because the folder went away after the check
|
|
3768
|
+
// above is told as that, too.
|
|
3769
|
+
if (!processRef.aborted && !abortController.signal.aborted) {
|
|
3770
|
+
streamError = explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl });
|
|
3771
|
+
}
|
|
3746
3772
|
} finally {
|
|
3747
3773
|
try {
|
|
3748
3774
|
processRef.turn.endInput?.();
|
|
@@ -4221,7 +4247,7 @@ export function createClaudeSessionRunner({
|
|
|
4221
4247
|
settingsWindowOpen = false;
|
|
4222
4248
|
const errorText = String(error?.message || error || 'unknown error');
|
|
4223
4249
|
dbg('claude turn failed', message.id, errorText);
|
|
4224
|
-
await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error) });
|
|
4250
|
+
await publisher.publishTurnException({ message, errorText, usageLimit: usageLimit.classifyException(error), error });
|
|
4225
4251
|
return true;
|
|
4226
4252
|
} finally {
|
|
4227
4253
|
endAdmission();
|
|
@@ -4262,7 +4288,7 @@ export function createClaudeSessionRunner({
|
|
|
4262
4288
|
* subagents have no task id and remain full-turn-Stop only.
|
|
4263
4289
|
*/
|
|
4264
4290
|
async function stopBackgroundTaskByToolUseId(toolUseId) {
|
|
4265
|
-
const normalized =
|
|
4291
|
+
const normalized = timelineRecorder.runIdFor(toolUseId);
|
|
4266
4292
|
if (!normalized || !proc) return false;
|
|
4267
4293
|
for (const [taskId, task] of proc.liveTasks.entries()) {
|
|
4268
4294
|
if (String(task?.toolUseId || '').trim() === normalized) {
|
|
@@ -4,6 +4,7 @@ import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
|
4
4
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
5
5
|
import { buildSteerSettleFailure, steerSettleFailureText } from '../../shared/steer-settle-failure.mjs';
|
|
6
6
|
import { buildUsageLimitFailure } from '../../shared/claude-usage-limit.mjs';
|
|
7
|
+
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
7
8
|
|
|
8
9
|
const PLAN_BOARD_ACTIONS = [
|
|
9
10
|
{ id: 'autopilot', label: 'Implement in autopilot', mode: 'autopilot' },
|
|
@@ -390,7 +391,7 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
390
391
|
});
|
|
391
392
|
}
|
|
392
393
|
|
|
393
|
-
async function publishTurnException({ message, errorText, usageLimit = null }) {
|
|
394
|
+
async function publishTurnException({ message, errorText, usageLimit = null, error = null }) {
|
|
394
395
|
if (usageLimit) {
|
|
395
396
|
await publishResponse(message, {
|
|
396
397
|
text: usageLimit.text || errorText,
|
|
@@ -399,6 +400,24 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
|
|
|
399
400
|
});
|
|
400
401
|
return;
|
|
401
402
|
}
|
|
403
|
+
// The conversation's folder is gone: the error names it and says what to
|
|
404
|
+
// do, which neither a retry nor a sign-in changes.
|
|
405
|
+
if (isWorkingDirectoryMissingError(error)) {
|
|
406
|
+
await publishResponse(message, {
|
|
407
|
+
text: `System note: ${error.message} ${error.guidance}`,
|
|
408
|
+
model: null,
|
|
409
|
+
terminalError: {
|
|
410
|
+
kind: 'claude-turn-failed',
|
|
411
|
+
code: WORKING_DIRECTORY_MISSING_CODE,
|
|
412
|
+
stableCode: `claude.${WORKING_DIRECTORY_MISSING_CODE}`,
|
|
413
|
+
message: error.message,
|
|
414
|
+
guidance: error.guidance,
|
|
415
|
+
failedAt: new Date().toISOString(),
|
|
416
|
+
queueMessageId: String(message.id || '') || null,
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
402
421
|
const isAuthError = /authentication|logged in|login|credential|api key/i.test(errorText);
|
|
403
422
|
await publishResponse(message, {
|
|
404
423
|
text: isAuthError
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import { displayToolName, isBackgroundLaunch, isSubagentToolName } from './sdk-message-normalizer.mjs';
|
|
3
3
|
import {
|
|
4
|
+
SUBAGENT_TASK_TYPES,
|
|
4
5
|
SUBAGENT_TIMELINE_ENTRY_MAX_CHARS,
|
|
5
6
|
SUBAGENT_TIMELINE_MAX_ENTRIES,
|
|
6
7
|
cutSubagentTimelineText,
|
|
@@ -8,6 +9,8 @@ import {
|
|
|
8
9
|
} from '../../shared/subagent-timeline.mjs';
|
|
9
10
|
|
|
10
11
|
const MAX_TRACKED_RUNS = 200;
|
|
12
|
+
// Task ids and resume ids remembered to find a resumed agent's run.
|
|
13
|
+
const MAX_TRACKED_TASKS = 500;
|
|
11
14
|
|
|
12
15
|
export function toolResultText(content) {
|
|
13
16
|
if (typeof content === 'string') return content;
|
|
@@ -53,6 +56,16 @@ function capText(text) {
|
|
|
53
56
|
*
|
|
54
57
|
* It also answers which turn owns a run's live lanes (`ownsRun`): only the
|
|
55
58
|
* turn that spawned an agent shows its frames in its in-turn lanes.
|
|
59
|
+
*
|
|
60
|
+
* A finished background agent that the session sends a new message
|
|
61
|
+
* (SendMessage) runs again as the same task: task_started names its task id
|
|
62
|
+
* again, with the SendMessage call as its tool_use_id. That id leads to the
|
|
63
|
+
* run of the task's first start (`runIdFor`), so the record goes on under the
|
|
64
|
+
* agent's run, running again, with a note where it resumed. A start the
|
|
65
|
+
* recorder already saw (same run id) adds nothing, and an event of an
|
|
66
|
+
* earlier run (a lower run id) is late and left out. Every post names
|
|
67
|
+
* the run's task id too: a relay that knows the run by the task alone (this
|
|
68
|
+
* recorder did not see the first start) places it there.
|
|
56
69
|
*/
|
|
57
70
|
export function createSubagentTimelineRecorder({
|
|
58
71
|
api,
|
|
@@ -66,6 +79,9 @@ export function createSubagentTimelineRecorder({
|
|
|
66
79
|
writerId = crypto.randomBytes(4).toString('hex'),
|
|
67
80
|
} = {}) {
|
|
68
81
|
const runs = new Map(); // runId -> run state, in registration order
|
|
82
|
+
const taskRuns = new Map(); // taskId -> the run its first start named
|
|
83
|
+
const resumeRuns = new Map(); // tool_use_id of a later start -> that run
|
|
84
|
+
const latestRuns = new Map(); // taskId -> the SDK run id of its latest start
|
|
69
85
|
let flushTimer = null;
|
|
70
86
|
let flushing = null;
|
|
71
87
|
let localCounter = 0;
|
|
@@ -75,6 +91,26 @@ export function createSubagentTimelineRecorder({
|
|
|
75
91
|
return `${writerId}:${localCounter}`;
|
|
76
92
|
}
|
|
77
93
|
|
|
94
|
+
/** The run an id belongs to: a resumed agent's later tool use leads to its first run. */
|
|
95
|
+
function runIdFor(id) {
|
|
96
|
+
const key = String(id || '').trim();
|
|
97
|
+
return resumeRuns.get(key) || key;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// An event of an earlier run of a task than its latest start: late (the
|
|
101
|
+
// run ids of one task sort in the order the runs opened). Without run ids,
|
|
102
|
+
// every event counts.
|
|
103
|
+
function isEarlierRun(taskId, sdkRunId) {
|
|
104
|
+
const latest = taskId ? latestRuns.get(taskId) : '';
|
|
105
|
+
return !!sdkRunId && !!latest && sdkRunId < latest;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function remember(map, key, value) {
|
|
109
|
+
map.delete(key);
|
|
110
|
+
map.set(key, value);
|
|
111
|
+
while (map.size > MAX_TRACKED_TASKS) map.delete(map.keys().next().value);
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
function ensureRun(runId, { parentId = null, displayName = '', subagentType = '' } = {}) {
|
|
79
115
|
const id = String(runId || '').trim();
|
|
80
116
|
if (!id) return null;
|
|
@@ -221,7 +257,7 @@ export function createSubagentTimelineRecorder({
|
|
|
221
257
|
}
|
|
222
258
|
|
|
223
259
|
function observeAssistant(sdkMessage) {
|
|
224
|
-
const parentId =
|
|
260
|
+
const parentId = runIdFor(sdkMessage?.parent_tool_use_id);
|
|
225
261
|
const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
226
262
|
if (!parentId) {
|
|
227
263
|
// The main thread: only the agents it spawns matter here.
|
|
@@ -261,7 +297,7 @@ export function createSubagentTimelineRecorder({
|
|
|
261
297
|
}
|
|
262
298
|
|
|
263
299
|
function observeStreamEvent(sdkMessage) {
|
|
264
|
-
const parentId =
|
|
300
|
+
const parentId = runIdFor(sdkMessage?.parent_tool_use_id);
|
|
265
301
|
if (!parentId) return;
|
|
266
302
|
const run = ensureRun(parentId);
|
|
267
303
|
const event = sdkMessage?.event || {};
|
|
@@ -309,7 +345,7 @@ export function createSubagentTimelineRecorder({
|
|
|
309
345
|
}
|
|
310
346
|
|
|
311
347
|
function observeUser(sdkMessage) {
|
|
312
|
-
const parentId =
|
|
348
|
+
const parentId = runIdFor(sdkMessage?.parent_tool_use_id);
|
|
313
349
|
const content = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
314
350
|
for (const block of content) {
|
|
315
351
|
if (block?.type !== 'tool_result') continue;
|
|
@@ -346,17 +382,46 @@ export function createSubagentTimelineRecorder({
|
|
|
346
382
|
const subtype = String(sdkMessage?.subtype || '');
|
|
347
383
|
const toolUseId = String(sdkMessage?.tool_use_id || '').trim();
|
|
348
384
|
if (!toolUseId) return;
|
|
385
|
+
const taskId = String(sdkMessage?.task_id || '').trim();
|
|
386
|
+
const sdkRunId = String(sdkMessage?.run_id || '').trim();
|
|
349
387
|
if (subtype === 'task_started') {
|
|
350
|
-
|
|
388
|
+
// Only sub-agents are resumed; shells and the rest are not remembered.
|
|
389
|
+
const taskType = String(sdkMessage.task_type || '').trim();
|
|
390
|
+
const agent = !!taskId && (!taskType || SUBAGENT_TASK_TYPES.has(taskType));
|
|
391
|
+
if (agent && sdkRunId) {
|
|
392
|
+
// The same start again, or an earlier run's start arriving late.
|
|
393
|
+
const latest = latestRuns.get(taskId);
|
|
394
|
+
if (latest && sdkRunId <= latest) return;
|
|
395
|
+
remember(latestRuns, taskId, sdkRunId);
|
|
396
|
+
}
|
|
397
|
+
const firstRun = agent ? taskRuns.get(taskId) : '';
|
|
398
|
+
if (firstRun && firstRun !== toolUseId) remember(resumeRuns, toolUseId, firstRun);
|
|
399
|
+
else if (agent && !firstRun) remember(taskRuns, taskId, toolUseId);
|
|
400
|
+
const run = ensureRun(runIdFor(toolUseId), { subagentType: String(sdkMessage.subagent_type || '').trim() });
|
|
401
|
+
// The same task started again (under another tool use, or after it
|
|
402
|
+
// ended): resumed.
|
|
403
|
+
const resumed = !!firstRun && (firstRun !== toolUseId || run.settled);
|
|
404
|
+
run.settled = false;
|
|
351
405
|
if (sdkMessage.is_backgrounded === true) run.background = true;
|
|
406
|
+
if (resumed) {
|
|
407
|
+
const prompt = String(sdkMessage.prompt || '').trim();
|
|
408
|
+
addEntry(run, {
|
|
409
|
+
kind: 'note',
|
|
410
|
+
text: capText(prompt ? `Resumed with a new message: ${prompt}` : 'Resumed with a new message'),
|
|
411
|
+
metadata: { resumed: true },
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
// Running: a start, and for a resumed agent its return from the end.
|
|
352
415
|
patchHeader(run, {
|
|
353
|
-
|
|
416
|
+
status: 'running',
|
|
417
|
+
taskId: taskId || null,
|
|
354
418
|
subagentType: String(sdkMessage.subagent_type || '').trim() || null,
|
|
355
419
|
background: sdkMessage.is_backgrounded === true ? true : null,
|
|
356
420
|
});
|
|
357
421
|
return;
|
|
358
422
|
}
|
|
359
|
-
|
|
423
|
+
if (isEarlierRun(taskId, sdkRunId)) return;
|
|
424
|
+
const run = runs.get(runIdFor(toolUseId));
|
|
360
425
|
if (!run) return;
|
|
361
426
|
if (subtype === 'task_updated') {
|
|
362
427
|
if (sdkMessage?.patch?.is_backgrounded === true) {
|
|
@@ -413,7 +478,7 @@ export function createSubagentTimelineRecorder({
|
|
|
413
478
|
}
|
|
414
479
|
|
|
415
480
|
function ownsRun(runId, owner) {
|
|
416
|
-
const run = runs.get(
|
|
481
|
+
const run = runs.get(runIdFor(runId));
|
|
417
482
|
if (!run || !owner) return false;
|
|
418
483
|
if (!run.owner && run.parentId) run.owner = runs.get(run.parentId)?.owner || null;
|
|
419
484
|
return run.owner === owner;
|
|
@@ -426,7 +491,7 @@ export function createSubagentTimelineRecorder({
|
|
|
426
491
|
* carries it now; an unknown run keeps the old behaviour.
|
|
427
492
|
*/
|
|
428
493
|
function isForeignRun(runId, owner) {
|
|
429
|
-
let run = runs.get(
|
|
494
|
+
let run = runs.get(runIdFor(runId));
|
|
430
495
|
if (!run) return false;
|
|
431
496
|
const spawnOwner = run.owner || null;
|
|
432
497
|
let background = false;
|
|
@@ -469,6 +534,7 @@ export function createSubagentTimelineRecorder({
|
|
|
469
534
|
await api('POST', '/api/subagent-timeline', {
|
|
470
535
|
conversationId,
|
|
471
536
|
subagentRunId: run.id,
|
|
537
|
+
...(run.header.taskId ? { taskId: run.header.taskId } : {}),
|
|
472
538
|
entries,
|
|
473
539
|
...(header ? { header } : {}),
|
|
474
540
|
});
|
|
@@ -566,6 +632,7 @@ export function createSubagentTimelineRecorder({
|
|
|
566
632
|
flush,
|
|
567
633
|
processEnded,
|
|
568
634
|
hasPending,
|
|
635
|
+
runIdFor,
|
|
569
636
|
// For tests and diagnostics.
|
|
570
637
|
runState: (runId) => runs.get(String(runId || '').trim()) || null,
|
|
571
638
|
};
|
|
@@ -40,7 +40,7 @@ export const RELAY_ASK_USER_TOOL_PARAMETERS = Object.freeze({
|
|
|
40
40
|
choices: {
|
|
41
41
|
type: 'array',
|
|
42
42
|
items: { type: 'string' },
|
|
43
|
-
description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible.',
|
|
43
|
+
description: 'Optional list of choices for a multiple choice question. Prefer providing choices when possible. The relay keeps the first 8 choices and drops the rest.',
|
|
44
44
|
},
|
|
45
45
|
multi_select: {
|
|
46
46
|
type: 'boolean',
|
|
@@ -80,7 +80,9 @@ export function createCopilotPromptContextBuilder({
|
|
|
80
80
|
if (includeInstructions) {
|
|
81
81
|
// Advisory: a preview lookup that fails costs the block, never the turn.
|
|
82
82
|
const previewBlock = typeof getPreviewInstructions === 'function'
|
|
83
|
-
? await Promise.resolve()
|
|
83
|
+
? await Promise.resolve()
|
|
84
|
+
.then(() => getPreviewInstructions({ conversationId: String(message?.conversationId || '') }))
|
|
85
|
+
.catch(() => '')
|
|
84
86
|
: '';
|
|
85
87
|
instructions = applyMediaEmbedInstructions(applyPreviewInstructions(toolInstructions, previewBlock));
|
|
86
88
|
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
26
26
|
import { createRelayQuestion } from '../../shared/question-wait.mjs';
|
|
27
27
|
import { looksLikeMultiSelectQuestion } from '../../shared/question-multi-select.mjs';
|
|
28
|
+
import { readApprovalAnswer } from '../../shared/question-answer-rule.mjs';
|
|
28
29
|
import {
|
|
29
30
|
DEFAULT_QUESTION_TIMEOUT_MS,
|
|
30
31
|
QUESTION_TIMEOUT_CONTINUATION_TEXT,
|
|
@@ -262,9 +263,10 @@ export function createCopilotQuestionBridge({
|
|
|
262
263
|
/**
|
|
263
264
|
* `onPermissionRequest` in ask mode → an approve/deny card.
|
|
264
265
|
*
|
|
265
|
-
* Freeform is allowed and anything
|
|
266
|
-
* so a human can type *why* they are
|
|
267
|
-
* feedback instead of a bare refusal.
|
|
266
|
+
* Freeform is allowed and anything but the approve choice (by the shared
|
|
267
|
+
* rule: trimmed, any case) denies — so a human can type *why* they are
|
|
268
|
+
* refusing and the model receives it as feedback instead of a bare refusal.
|
|
269
|
+
* The card's rationale says so, since a typed "yes" refuses too.
|
|
268
270
|
*/
|
|
269
271
|
async function askToolApproval(request, { signal, message } = {}) {
|
|
270
272
|
const description = describePermissionRequest(request);
|
|
@@ -273,21 +275,24 @@ export function createCopilotQuestionBridge({
|
|
|
273
275
|
choices: [PERMISSION_APPROVE_CHOICE, PERMISSION_DENY_CHOICE],
|
|
274
276
|
allowFreeform: true,
|
|
275
277
|
source: 'onPermissionRequest',
|
|
276
|
-
rationale: 'Copilot requested permission to run a tool in ask mode.',
|
|
278
|
+
rationale: 'Copilot requested permission to run a tool in ask mode. Approve runs it; Deny or a typed reply refuses it, and a typed reply tells Copilot why.',
|
|
277
279
|
extra: {
|
|
278
280
|
requestId: String(request?.requestId || '') || undefined,
|
|
279
281
|
permissionKind: String(request?.kind || '') || undefined,
|
|
280
282
|
},
|
|
281
283
|
}, { signal, message });
|
|
282
284
|
const answer = String(result?.answer ?? '').trim();
|
|
283
|
-
const
|
|
285
|
+
const decision = result?.timedOut
|
|
286
|
+
? null
|
|
287
|
+
: readApprovalAnswer(answer, { approve: PERMISSION_APPROVE_CHOICE, deny: PERMISSION_DENY_CHOICE });
|
|
288
|
+
const approved = decision === 'approve';
|
|
284
289
|
// The denial note is composed HERE rather than in the permission handler so
|
|
285
290
|
// the handler never needs to know the card's choice labels — that would be
|
|
286
291
|
// an import cycle (this module already imports the request describer from
|
|
287
292
|
// the adapter). A freeform denial carries the human's own reason; a plain
|
|
288
293
|
// "Deny" click does not, so echoing the button label back at the model as
|
|
289
294
|
// though it were an explanation is avoided.
|
|
290
|
-
const feedback = answer &&
|
|
295
|
+
const feedback = answer && decision !== 'deny'
|
|
291
296
|
? answer
|
|
292
297
|
: 'The user declined this action.';
|
|
293
298
|
return { approved, answer, feedback, timedOut: result?.timedOut === true, description };
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
} from './copilot-model-switch.mjs';
|
|
33
33
|
import { copilotRuntimeEntry } from '../copilot-sdk-runtime.mjs';
|
|
34
34
|
import { describeTurnStall, isTurnStalledError } from '../../shared/worker-runtime/turn-liveness.mjs';
|
|
35
|
+
import { WORKING_DIRECTORY_MISSING_CODE, isWorkingDirectoryMissingError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
35
36
|
|
|
36
37
|
const VERSION_DIR_RE = /^\d+\.\d+\.\d+$/;
|
|
37
38
|
|
|
@@ -510,6 +511,16 @@ export const COPILOT_TURN_CONTINUE_GUIDANCE =
|
|
|
510
511
|
*/
|
|
511
512
|
export function classifyCopilotTurnException(error) {
|
|
512
513
|
const detail = String(error?.message || error || '').trim() || 'unknown error';
|
|
514
|
+
// The conversation's folder is gone; the message names it.
|
|
515
|
+
if (isWorkingDirectoryMissingError(error)) {
|
|
516
|
+
return {
|
|
517
|
+
code: WORKING_DIRECTORY_MISSING_CODE,
|
|
518
|
+
stableCode: `copilot.${WORKING_DIRECTORY_MISSING_CODE}`,
|
|
519
|
+
text: detail,
|
|
520
|
+
guidance: error.guidance,
|
|
521
|
+
detail,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
513
524
|
// An unconfirmed explicit model selection (audit #13). The error's own
|
|
514
525
|
// message already names requested vs actual model, so it IS the user-facing
|
|
515
526
|
// text; the stable code is what lets the UI (and tests) key on it.
|
|
@@ -124,6 +124,7 @@ import { createTaskOutputSpool } from '../../shared/worker-runtime/task-output-s
|
|
|
124
124
|
import { createShellOutputMirror } from './copilot-shell-output-mirror.mjs';
|
|
125
125
|
import { isTransientProgressText, settleForTask, shellExitFromEvent } from './copilot-task-settle.mjs';
|
|
126
126
|
import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
|
|
127
|
+
import { explainLaunchFailure, missingWorkingDirectoryError } from '../../shared/worker-runtime/working-directory.mjs';
|
|
127
128
|
|
|
128
129
|
// How long the runtime may sit with no session activity before the worker
|
|
129
130
|
// closes it. The worker process itself stays up and reconnects lazily on the
|
|
@@ -369,6 +370,9 @@ export function createCopilotSdkSessionRunner({
|
|
|
369
370
|
// module imports the real SDK (that lives in copilot-sdk-adapter.mjs).
|
|
370
371
|
resolvePathsImpl = resolveCopilotSdkPaths,
|
|
371
372
|
startClientImpl = startCopilotClient,
|
|
373
|
+
// The error a runtime started in `cwd` fails with up front, or null
|
|
374
|
+
// (shared/worker-runtime/working-directory.mjs).
|
|
375
|
+
checkWorkingDirectoryImpl = missingWorkingDirectoryError,
|
|
372
376
|
createNormalizerImpl = createCopilotEventNormalizer,
|
|
373
377
|
buildMessageOptionsImpl = buildCopilotMessageOptions,
|
|
374
378
|
// BYOK: `COPILOT_PROVIDER_*` in this worker's env become
|
|
@@ -2622,8 +2626,17 @@ export function createCopilotSdkSessionRunner({
|
|
|
2622
2626
|
if (client) return client;
|
|
2623
2627
|
if (starting) return starting;
|
|
2624
2628
|
starting = (async () => {
|
|
2629
|
+
// The runtime is spawned in the conversation's folder: a folder that is
|
|
2630
|
+
// gone fails the turn with its name, not with the spawn's ENOENT.
|
|
2631
|
+
const missingFolder = checkWorkingDirectoryImpl(cwd);
|
|
2632
|
+
if (missingFolder) throw missingFolder;
|
|
2625
2633
|
sdkPaths = resolvePathsImpl({ env });
|
|
2626
|
-
|
|
2634
|
+
let started;
|
|
2635
|
+
try {
|
|
2636
|
+
started = await startClientImpl({ paths: sdkPaths, cwd, clientName, logLevel, dbg });
|
|
2637
|
+
} catch (error) {
|
|
2638
|
+
throw explainLaunchFailure(error, cwd, { check: checkWorkingDirectoryImpl });
|
|
2639
|
+
}
|
|
2627
2640
|
client = started.client;
|
|
2628
2641
|
detachRuntimeExit = observeRuntimeExit(client, handleRuntimeExit);
|
|
2629
2642
|
runtimeTree = watchRuntimeTree(client);
|
|
@@ -10,7 +10,7 @@ export const ASK_USER_TOOL_DESCRIPTION =
|
|
|
10
10
|
+ 'This is the ONLY way to ask the user anything: a question written as plain '
|
|
11
11
|
+ 'reply text is NOT shown interactively and ends the turn unanswered. Call '
|
|
12
12
|
+ 'this tool whenever user input would materially change the result, before '
|
|
13
|
-
+ 'making assumptions. Provide 2-
|
|
13
|
+
+ 'making assumptions. Provide 2-6 concise options per question; the user can '
|
|
14
14
|
+ 'always answer in free text instead. This tool blocks until the user '
|
|
15
15
|
+ 'responds; on timeout it returns an instruction to continue under the '
|
|
16
16
|
+ 'current relay mode.';
|