@canonmsg/claude-code-plugin 0.22.0 → 0.24.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/.claude-plugin/plugin.json +25 -0
- package/README.md +18 -2
- package/dist/approval-diff.d.ts +1 -1
- package/dist/approval-diff.js +1 -1
- package/dist/approval-server.d.ts +33 -0
- package/dist/approval-server.js +200 -0
- package/dist/cli-entry.d.ts +2 -2
- package/dist/cli-entry.js +1 -1
- package/dist/host.d.ts +1 -1
- package/dist/host.js +1079 -678
- package/dist/inbound-policy.d.ts +22 -0
- package/dist/inbound-policy.js +46 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp-args.d.ts +45 -0
- package/dist/mcp-args.js +179 -0
- package/dist/register.js +25 -73
- package/dist/server.d.ts +2 -0
- package/dist/server.js +699 -0
- package/dist/session-state.d.ts +47 -54
- package/dist/session-state.js +88 -166
- package/dist/setup.d.ts +3 -2
- package/dist/setup.js +29 -5
- package/dist/startup-recovery.d.ts +46 -0
- package/dist/startup-recovery.js +59 -0
- package/dist/streaming-buffer.d.ts +16 -0
- package/dist/streaming-buffer.js +52 -0
- package/dist/tool-policy.d.ts +1 -2
- package/package.json +11 -11
- package/skills/configure/SKILL.md +10 -4
- package/dist/bridge-bin.d.ts +0 -14
- package/dist/bridge-bin.js +0 -27
- package/dist/session-defaults.d.ts +0 -35
- package/dist/session-defaults.js +0 -124
- package/dist/subagent-activity.d.ts +0 -11
- package/dist/subagent-activity.js +0 -51
package/dist/host.js
CHANGED
|
@@ -26,19 +26,14 @@ import { execFileSync } from 'node:child_process';
|
|
|
26
26
|
import { existsSync } from 'node:fs';
|
|
27
27
|
import { readFile } from 'node:fs/promises';
|
|
28
28
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildFirstPartyCodingRuntimeDescriptor, buildPlanApprovalRequest, resolveQuestionAllowOther, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, EXECUTION_ENVIRONMENT_MODES, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, normalizeTurnMetadata, renderCanonHostInboundContent, renderCodingHostInboundPrompt, } from '@canonmsg/core/contract';
|
|
34
|
-
import { ExecutionEnvironmentError, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildLocalRuntimeId, buildPublicWorkspaceOptions, buildPublicWorkspaceRoots, clearRuntimeSessionState, heartbeatLocalRuntimeEntry, loadRuntimeSessionState, markLocalRuntimeStopped, prepareConversationEnvironment, releaseConversationEnvironment, resolveCanonAgent, saveRuntimeSessionState, upsertLocalRuntimeEntry, } from '@canonmsg/core/local';
|
|
35
|
-
import { ApprovalManager, readHostSessionConfig, resolveHostWorkspaceCwd, } from '@canonmsg/core/host';
|
|
29
|
+
import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
30
|
+
import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
|
|
31
|
+
import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
32
|
+
import { decideAutoReply, } from './inbound-policy.js';
|
|
36
33
|
import { runCli } from './cli-entry.js';
|
|
37
34
|
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
38
35
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
39
|
-
import { applyClaudeSessionControl, buildClaudeFinalMessageId,
|
|
40
|
-
import { claudeThinkingToConfig, parseClaudeSessionDefaults, resolveEffectiveSessionSettings, } from './session-defaults.js';
|
|
41
|
-
import { mapClaudeSubagentActivity } from './subagent-activity.js';
|
|
36
|
+
import { applyClaudeSessionControl, buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
|
|
42
37
|
function parseRuntimeVisibilityPreset(value) {
|
|
43
38
|
return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
|
|
44
39
|
}
|
|
@@ -77,11 +72,6 @@ Keep this terminal open while you want Canon to reach the agent. Closing it,
|
|
|
77
72
|
logging out, rebooting, or sleeping long enough to stop the process takes the
|
|
78
73
|
local agent offline until you revive it. Docs:
|
|
79
74
|
https://canonmail.com/agents/integrations`;
|
|
80
|
-
// This host process resolves and locks exactly one agent profile. The lock
|
|
81
|
-
// handle returned by resolveCanonAgent is held here so the top-level runCli
|
|
82
|
-
// error handler (outside main's scope) can release it on a failed start —
|
|
83
|
-
// there is no core module-global profile-lock to fall back on.
|
|
84
|
-
let activeLockHandle = null;
|
|
85
75
|
let workingDir = process.cwd();
|
|
86
76
|
let workspaceOptions = [];
|
|
87
77
|
let workspaceRoots = [];
|
|
@@ -89,29 +79,6 @@ let workspaceRootMetadata = [];
|
|
|
89
79
|
let runtimeModels = [];
|
|
90
80
|
const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
91
81
|
const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
|
|
92
|
-
const CLAUDE_THINKING_CONTROL_OPTIONS = [
|
|
93
|
-
{
|
|
94
|
-
value: 'adaptive',
|
|
95
|
-
label: 'Adaptive',
|
|
96
|
-
description: 'Let Claude decide when and how much to think.',
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
value: 'off',
|
|
100
|
-
label: 'Off',
|
|
101
|
-
description: 'Disable extended thinking for this session.',
|
|
102
|
-
},
|
|
103
|
-
];
|
|
104
|
-
const CLAUDE_ULTRACODE_CONTROL_OPTIONS = [
|
|
105
|
-
{
|
|
106
|
-
value: 'off',
|
|
107
|
-
label: 'Off',
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
value: 'on',
|
|
111
|
-
label: 'On',
|
|
112
|
-
description: 'Enable Claude Code ultracode mode when the selected model supports it.',
|
|
113
|
-
},
|
|
114
|
-
];
|
|
115
82
|
let runtimeMetadata = {
|
|
116
83
|
account: null,
|
|
117
84
|
mcpServers: [],
|
|
@@ -228,6 +195,26 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
228
195
|
controlId: 'effort',
|
|
229
196
|
},
|
|
230
197
|
},
|
|
198
|
+
{
|
|
199
|
+
id: 'ultracode-details',
|
|
200
|
+
label: 'Ultracode',
|
|
201
|
+
description: 'Toggle Claude ultracode using /ultracode.',
|
|
202
|
+
aliases: ['ultracode'],
|
|
203
|
+
category: 'runtime',
|
|
204
|
+
placements: ['composer_slash', 'command_palette'],
|
|
205
|
+
availability: ['always'],
|
|
206
|
+
args: [{
|
|
207
|
+
id: 'level',
|
|
208
|
+
label: 'Level',
|
|
209
|
+
kind: 'enum',
|
|
210
|
+
required: true,
|
|
211
|
+
choices: [...CLAUDE_ULTRACODE_OPTIONS],
|
|
212
|
+
}],
|
|
213
|
+
dispatch: {
|
|
214
|
+
kind: 'control',
|
|
215
|
+
controlId: 'ultracode',
|
|
216
|
+
},
|
|
217
|
+
},
|
|
231
218
|
{
|
|
232
219
|
...RUNTIME_NEW_SESSION_ACTION,
|
|
233
220
|
primitive: 'session.new',
|
|
@@ -236,40 +223,24 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
236
223
|
RUNTIME_STOP_AND_DROP_ACTION,
|
|
237
224
|
];
|
|
238
225
|
commands.push(...buildNativeSlashCommandDescriptors(runtimeMetadata.commands, new Set(commands.flatMap((command) => [command.id, ...(command.aliases ?? [])]))));
|
|
239
|
-
const extraRuntimeControls = [
|
|
240
|
-
{
|
|
241
|
-
id: 'thinking',
|
|
242
|
-
label: 'Thinking',
|
|
243
|
-
description: 'Claude SDK thinking mode. Mid-session changes toggle thinking on or off; display/budget stay session-start scoped.',
|
|
244
|
-
options: CLAUDE_THINKING_CONTROL_OPTIONS,
|
|
245
|
-
defaultValue: input.defaults?.thinking ?? 'adaptive',
|
|
246
|
-
availability: 'setup_and_live',
|
|
247
|
-
liveBehavior: 'immediate',
|
|
248
|
-
selectionPolicy: 'inherit',
|
|
249
|
-
},
|
|
250
|
-
{
|
|
251
|
-
id: 'ultracode',
|
|
252
|
-
label: 'Ultracode',
|
|
253
|
-
description: 'Enable Claude Code ultracode mode when the selected model supports it.',
|
|
254
|
-
options: CLAUDE_ULTRACODE_CONTROL_OPTIONS,
|
|
255
|
-
defaultValue: input.defaults?.ultracode ?? 'off',
|
|
256
|
-
availability: 'setup_and_live',
|
|
257
|
-
liveBehavior: 'immediate',
|
|
258
|
-
selectionPolicy: 'inherit',
|
|
259
|
-
},
|
|
260
|
-
];
|
|
261
226
|
return buildFirstPartyCodingRuntimeDescriptor({
|
|
262
227
|
clientType: 'claude-code',
|
|
263
228
|
models: input.models,
|
|
264
|
-
defaultModel: input.defaults?.model ?? input.models[0]?.value ?? null,
|
|
265
229
|
workspaces: input.workspaces,
|
|
266
230
|
workspaceRoots: input.workspaceRoots,
|
|
267
231
|
executionModes: input.executionModes,
|
|
268
232
|
permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
|
|
269
233
|
defaultPermissionMode: 'default',
|
|
270
234
|
effortOptions: [...CLAUDE_EFFORT_OPTIONS],
|
|
271
|
-
|
|
272
|
-
|
|
235
|
+
extraRuntimeControls: [{
|
|
236
|
+
id: 'ultracode',
|
|
237
|
+
label: 'Ultracode',
|
|
238
|
+
options: [...CLAUDE_ULTRACODE_OPTIONS],
|
|
239
|
+
defaultValue: 'off',
|
|
240
|
+
availability: 'setup_and_live',
|
|
241
|
+
liveBehavior: 'immediate',
|
|
242
|
+
selectionPolicy: 'inherit',
|
|
243
|
+
}],
|
|
273
244
|
streamingTextMode: 'delta',
|
|
274
245
|
turnModes: [
|
|
275
246
|
{
|
|
@@ -295,6 +266,36 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
295
266
|
commands,
|
|
296
267
|
});
|
|
297
268
|
}
|
|
269
|
+
const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_OPTIONS.map((option) => option.value));
|
|
270
|
+
function parseClaudePermissionMode(value) {
|
|
271
|
+
if (value === 'acceptEdits'
|
|
272
|
+
|| value === 'auto'
|
|
273
|
+
|| value === 'bypassPermissions'
|
|
274
|
+
|| value === 'default'
|
|
275
|
+
|| value === 'dontAsk'
|
|
276
|
+
|| value === 'plan') {
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
function normalizeClaudeEffortLevel(value) {
|
|
282
|
+
return typeof value === 'string' && CLAUDE_EFFORT_VALUES.has(value)
|
|
283
|
+
? value
|
|
284
|
+
: undefined;
|
|
285
|
+
}
|
|
286
|
+
async function applyClaudeEffortLevel(q, effort) {
|
|
287
|
+
await q.applyFlagSettings({ effortLevel: effort });
|
|
288
|
+
}
|
|
289
|
+
const CLAUDE_ULTRACODE_OPTIONS = [
|
|
290
|
+
{ value: 'on', label: 'On' },
|
|
291
|
+
{ value: 'off', label: 'Off' },
|
|
292
|
+
];
|
|
293
|
+
function parseClaudeUltracodeMode(value) {
|
|
294
|
+
return value === 'on' || value === 'off' ? value : undefined;
|
|
295
|
+
}
|
|
296
|
+
async function applyClaudeUltracode(q, enabled) {
|
|
297
|
+
await q.applyFlagSettings({ ultracode: enabled });
|
|
298
|
+
}
|
|
298
299
|
let cachedClaudeCliPath;
|
|
299
300
|
// The SDK bundles a Bun-compiled `claude` Mach-O binary next to
|
|
300
301
|
// claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
|
|
@@ -331,36 +332,28 @@ function resolveClaudeCliPath() {
|
|
|
331
332
|
console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
|
|
332
333
|
return undefined;
|
|
333
334
|
}
|
|
334
|
-
const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus'];
|
|
335
|
-
function mergeModelOptions(...groups) {
|
|
336
|
-
const byValue = new Map();
|
|
337
|
-
for (const group of groups) {
|
|
338
|
-
for (const option of group) {
|
|
339
|
-
if (!byValue.has(option.value)) {
|
|
340
|
-
byValue.set(option.value, option);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return [...byValue.values()];
|
|
345
|
-
}
|
|
346
335
|
function toModelOptions(models) {
|
|
347
|
-
return models.map(
|
|
336
|
+
return models.map((model) => ({
|
|
337
|
+
value: model.value,
|
|
338
|
+
label: model.displayName,
|
|
339
|
+
}));
|
|
348
340
|
}
|
|
349
|
-
async function publishAgentRuntime(
|
|
350
|
-
await
|
|
351
|
-
clientType: 'claude-code',
|
|
352
|
-
runtime: runtime,
|
|
353
|
-
});
|
|
341
|
+
async function publishAgentRuntime(agentId, runtime) {
|
|
342
|
+
await publishHostAgentRuntime(agentId, 'claude-code', runtime);
|
|
354
343
|
}
|
|
355
|
-
async function loadSessionConfig(
|
|
356
|
-
const
|
|
357
|
-
|
|
344
|
+
async function loadSessionConfig(conversationId, agentId) {
|
|
345
|
+
const config = await loadHostSessionConfig({
|
|
346
|
+
conversationId,
|
|
347
|
+
agentId,
|
|
348
|
+
extraStringFields: ['permissionMode', 'effort'],
|
|
349
|
+
});
|
|
358
350
|
return {
|
|
359
351
|
...config,
|
|
360
352
|
permissionMode: config?.permissionMode
|
|
361
353
|
? parseClaudePermissionMode(config.permissionMode) ?? undefined
|
|
362
354
|
: undefined,
|
|
363
355
|
effort: normalizeClaudeEffortLevel(config?.effort),
|
|
356
|
+
ultracode: parseClaudeUltracodeMode(config?.runtimeControlValues?.ultracode),
|
|
364
357
|
};
|
|
365
358
|
}
|
|
366
359
|
function resolveSessionExecutionMode(config) {
|
|
@@ -384,21 +377,6 @@ function resolveExecutionFallbackReason(environment) {
|
|
|
384
377
|
: environment.reason;
|
|
385
378
|
}
|
|
386
379
|
async function detectRuntimeModels(cwd) {
|
|
387
|
-
const discovered = await detectRuntimeModelsForSelection(cwd, 'default');
|
|
388
|
-
const supplemental = await Promise.all(CLAUDE_SUPPLEMENTAL_MODEL_PROBES.map(async (model) => {
|
|
389
|
-
try {
|
|
390
|
-
const options = await detectRuntimeModelsForSelection(cwd, model);
|
|
391
|
-
const option = options.find((entry) => entry.value === model);
|
|
392
|
-
return option && !isClaudeCustomModelOption(option) ? [option] : [];
|
|
393
|
-
}
|
|
394
|
-
catch (error) {
|
|
395
|
-
console.error(`[canon-host] Supplemental Claude model probe failed for ${model}:`, error);
|
|
396
|
-
return [];
|
|
397
|
-
}
|
|
398
|
-
}));
|
|
399
|
-
return mergeModelOptions(discovered, ...supplemental);
|
|
400
|
-
}
|
|
401
|
-
async function detectRuntimeModelsForSelection(cwd, model) {
|
|
402
380
|
const emptyPrompt = {
|
|
403
381
|
async *[Symbol.asyncIterator]() { },
|
|
404
382
|
};
|
|
@@ -408,7 +386,6 @@ async function detectRuntimeModelsForSelection(cwd, model) {
|
|
|
408
386
|
options: {
|
|
409
387
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
410
388
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
411
|
-
model,
|
|
412
389
|
permissionMode: 'default',
|
|
413
390
|
cwd,
|
|
414
391
|
settings: { forceLoginMethod: 'claudeai' },
|
|
@@ -436,7 +413,6 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
|
|
|
436
413
|
options: {
|
|
437
414
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
438
415
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
439
|
-
model: 'default',
|
|
440
416
|
permissionMode: 'default',
|
|
441
417
|
cwd,
|
|
442
418
|
settings: { forceLoginMethod: 'claudeai' },
|
|
@@ -486,6 +462,9 @@ async function refreshClaudeRuntimeMetadata(input) {
|
|
|
486
462
|
});
|
|
487
463
|
return runtimeMetadataRefresh;
|
|
488
464
|
}
|
|
465
|
+
function sleep(ms) {
|
|
466
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
467
|
+
}
|
|
489
468
|
function mapClaudeQuestions(value) {
|
|
490
469
|
if (!Array.isArray(value))
|
|
491
470
|
return undefined;
|
|
@@ -617,22 +596,13 @@ function buildClaudeRuntimeInfo(input) {
|
|
|
617
596
|
tier: 'diagnostic',
|
|
618
597
|
}
|
|
619
598
|
: null,
|
|
620
|
-
|
|
599
|
+
runtimeModels[0]
|
|
621
600
|
? {
|
|
622
601
|
id: 'fallbackModel',
|
|
623
602
|
label: 'Fallback model',
|
|
624
|
-
value:
|
|
603
|
+
value: runtimeModels[0].label,
|
|
625
604
|
tier: 'diagnostic',
|
|
626
|
-
source: '
|
|
627
|
-
}
|
|
628
|
-
: null,
|
|
629
|
-
input.ultracodeEnabled
|
|
630
|
-
? {
|
|
631
|
-
id: 'ultracode',
|
|
632
|
-
label: 'Ultracode',
|
|
633
|
-
value: 'Enabled',
|
|
634
|
-
tier: 'detail',
|
|
635
|
-
source: 'applied',
|
|
605
|
+
source: 'host-default',
|
|
636
606
|
}
|
|
637
607
|
: null,
|
|
638
608
|
{
|
|
@@ -725,6 +695,9 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
725
695
|
const STREAMING_THROTTLE_MS = 250;
|
|
726
696
|
const HEARTBEAT_MS = 30_000;
|
|
727
697
|
const IDLE_CHECK_MS = 60_000;
|
|
698
|
+
const CONTROL_POLL_MS = 2000;
|
|
699
|
+
const IDLE_CONTROL_POLL_MS = 10_000;
|
|
700
|
+
const CONTROL_POLL_JITTER_MS = 1_000;
|
|
728
701
|
const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
729
702
|
const MAX_FINAL_DELIVERY_RETRIES = 1;
|
|
730
703
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
@@ -793,7 +766,7 @@ async function buildCanonUserContent(input) {
|
|
|
793
766
|
];
|
|
794
767
|
}
|
|
795
768
|
// ── Session factory ─────────────────────────────────────────────────
|
|
796
|
-
function createSession(conversationId, environment, agentId,
|
|
769
|
+
function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
797
770
|
const { cwd } = environment;
|
|
798
771
|
const cardResponseUserId = config.ownerId ?? undefined;
|
|
799
772
|
const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
|
|
@@ -853,15 +826,48 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
853
826
|
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
854
827
|
let session;
|
|
855
828
|
const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
829
|
+
function waitForReply(type, requestId) {
|
|
830
|
+
// Keep session alive while waiting for user input
|
|
831
|
+
session.lastActivity = Date.now();
|
|
832
|
+
return new Promise((resolve, reject) => {
|
|
833
|
+
const timer = setTimeout(() => {
|
|
834
|
+
session.pendingReply = null;
|
|
835
|
+
reject(new Error('Timed out waiting for user reply'));
|
|
836
|
+
}, REPLY_TIMEOUT_MS);
|
|
837
|
+
session.pendingReply = {
|
|
838
|
+
type,
|
|
839
|
+
requestId,
|
|
840
|
+
resolve: (metadata) => {
|
|
841
|
+
clearTimeout(timer);
|
|
842
|
+
resolve(metadata);
|
|
843
|
+
},
|
|
844
|
+
};
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
async function waitForRuntimeInputResponse(input) {
|
|
848
|
+
session.lastActivity = Date.now();
|
|
849
|
+
while (Date.now() < input.expiresAt) {
|
|
850
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
851
|
+
conversationId,
|
|
852
|
+
inputId: input.inputId,
|
|
853
|
+
}).catch(() => null);
|
|
854
|
+
if (response?.status === 'submitted') {
|
|
855
|
+
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
856
|
+
}
|
|
857
|
+
if (response?.status === 'cancelled' || response?.status === 'timeout') {
|
|
858
|
+
return { status: response.status };
|
|
859
|
+
}
|
|
860
|
+
await sleep(1_000);
|
|
861
|
+
}
|
|
862
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
863
|
+
conversationId,
|
|
864
|
+
inputId: input.inputId,
|
|
865
|
+
}).catch(() => null);
|
|
866
|
+
if (response?.status === 'submitted') {
|
|
867
|
+
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
868
|
+
}
|
|
869
|
+
return { status: 'timeout' };
|
|
870
|
+
}
|
|
865
871
|
function buildArtifactMediaMetadata() {
|
|
866
872
|
return {
|
|
867
873
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
@@ -873,27 +879,16 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
873
879
|
};
|
|
874
880
|
}
|
|
875
881
|
const claudeCliPath = resolveClaudeCliPath();
|
|
876
|
-
const initialThinkingConfig = claudeThinkingToConfig(config.sessionSettings.thinking);
|
|
877
882
|
const queryOptions = {
|
|
878
883
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
879
884
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
880
|
-
model: config.model ?? 'default',
|
|
881
885
|
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
882
886
|
allowDangerouslySkipPermissions: true,
|
|
883
887
|
cwd,
|
|
884
888
|
includePartialMessages: true,
|
|
885
889
|
maxTurns: 200,
|
|
886
890
|
...(config.effort ? { effort: config.effort } : {}),
|
|
887
|
-
|
|
888
|
-
...(config.sessionSettings.fallbackModel ? { fallbackModel: config.sessionSettings.fallbackModel } : {}),
|
|
889
|
-
agentProgressSummaries: config.sessionSettings.agentProgressSummaries ?? true,
|
|
890
|
-
...(config.sessionSettings.agents ? { agents: config.sessionSettings.agents } : {}),
|
|
891
|
-
settings: {
|
|
892
|
-
forceLoginMethod: 'claudeai',
|
|
893
|
-
...(config.sessionSettings.ultracode
|
|
894
|
-
? { ultracode: config.sessionSettings.ultracode === 'on' }
|
|
895
|
-
: {}),
|
|
896
|
-
},
|
|
891
|
+
settings: { forceLoginMethod: 'claudeai' },
|
|
897
892
|
settingSources: ['project', 'local'],
|
|
898
893
|
...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
|
|
899
894
|
stderr: (data) => {
|
|
@@ -908,27 +903,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
908
903
|
const questionId = randomUUID();
|
|
909
904
|
const questions = mapClaudeQuestions(input.questions);
|
|
910
905
|
const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
|
|
911
|
-
await
|
|
906
|
+
await client.createRuntimeInputRequest({
|
|
912
907
|
conversationId,
|
|
913
908
|
inputId: questionId,
|
|
914
909
|
kind: 'clarify',
|
|
915
|
-
expiresAt
|
|
910
|
+
expiresAt,
|
|
916
911
|
title: 'Claude needs input',
|
|
917
912
|
prompt: questions?.length
|
|
918
913
|
? 'Claude needs your input to continue.'
|
|
919
914
|
: 'Claude needs input to continue.',
|
|
920
|
-
...(questions
|
|
921
|
-
? { questions: questions }
|
|
922
|
-
: { questions: [] }),
|
|
915
|
+
...(questions ? { questions } : {}),
|
|
923
916
|
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
924
917
|
...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
|
|
925
918
|
native: {
|
|
926
919
|
runtime: 'claude-code',
|
|
927
920
|
method: 'AskUserQuestion',
|
|
928
921
|
requestId: questionId,
|
|
929
|
-
|
|
922
|
+
turnId: session.currentTurnId ?? undefined,
|
|
930
923
|
},
|
|
931
|
-
|
|
924
|
+
turnId: session.currentTurnId ?? undefined,
|
|
932
925
|
});
|
|
933
926
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
934
927
|
const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
|
|
@@ -983,9 +976,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
983
976
|
...(cardResponseUser ?? {}),
|
|
984
977
|
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
985
978
|
});
|
|
986
|
-
await
|
|
987
|
-
conversationId,
|
|
988
|
-
text: planApproval.text,
|
|
979
|
+
await sendMessageWithRetry(client, conversationId, planApproval.text, {
|
|
989
980
|
messageId: `claude-plan-${planId}`,
|
|
990
981
|
...(session.activeSelfContextId
|
|
991
982
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -1037,6 +1028,9 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1037
1028
|
if (session.state.permissionMode === 'plan') {
|
|
1038
1029
|
session.state.permissionMode = 'default';
|
|
1039
1030
|
writeState();
|
|
1031
|
+
// Converge the app immediately on the SDK-driven plan-mode exit
|
|
1032
|
+
// instead of waiting for the next 30s heartbeat (lag fix).
|
|
1033
|
+
config.publishSessionSnapshots?.([conversationId]);
|
|
1040
1034
|
}
|
|
1041
1035
|
return { behavior: 'allow' };
|
|
1042
1036
|
}
|
|
@@ -1101,13 +1095,13 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1101
1095
|
query: q,
|
|
1102
1096
|
sendInput,
|
|
1103
1097
|
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
|
|
1104
|
-
state:
|
|
1105
|
-
model:
|
|
1098
|
+
state: {
|
|
1099
|
+
model: undefined,
|
|
1106
1100
|
permissionMode: config.permissionMode,
|
|
1107
|
-
effort:
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
}
|
|
1101
|
+
effort: undefined,
|
|
1102
|
+
ultracode: undefined,
|
|
1103
|
+
state: 'idle',
|
|
1104
|
+
},
|
|
1111
1105
|
availableModels: config.availableModels,
|
|
1112
1106
|
streamingText: '',
|
|
1113
1107
|
streamingTimer: null,
|
|
@@ -1139,92 +1133,155 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1139
1133
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1140
1134
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1141
1135
|
};
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1136
|
+
const streamingOutput = createTurnOutputController({
|
|
1137
|
+
turnId: `claude-${conversationId}`,
|
|
1138
|
+
mode: 'delta',
|
|
1139
|
+
throttleMs: STREAMING_THROTTLE_MS,
|
|
1140
|
+
writeSnapshot: (snapshot) => {
|
|
1141
|
+
if (session.closed)
|
|
1142
|
+
return Promise.resolve();
|
|
1143
|
+
const turnId = session.currentTurnId ?? snapshot.turnId;
|
|
1144
|
+
session.streamingText = snapshot.text;
|
|
1145
|
+
const blocks = snapshot.blocks?.map((block) => ({ ...block, turnId }));
|
|
1146
|
+
return runtimeState.writeStreaming(conversationId, {
|
|
1147
|
+
text: snapshot.text,
|
|
1148
|
+
status: snapshot.status,
|
|
1149
|
+
messageId: turnId,
|
|
1150
|
+
turnId,
|
|
1151
|
+
blocks,
|
|
1152
|
+
});
|
|
1153
|
+
},
|
|
1154
|
+
clearSnapshot: () => {
|
|
1155
|
+
session.streamingText = '';
|
|
1156
|
+
return runtimeState.clearStreaming(conversationId);
|
|
1157
|
+
},
|
|
1163
1158
|
});
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1159
|
+
// ── Per-session streaming helpers ──
|
|
1160
|
+
function flushStreaming() {
|
|
1161
|
+
streamingOutput.flush().catch(() => { });
|
|
1162
|
+
}
|
|
1163
|
+
function onStreamDelta(text, segmentId) {
|
|
1164
|
+
if (segmentId) {
|
|
1165
|
+
streamingOutput.appendTextSegmentDelta(segmentId, text);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
streamingOutput.appendDelta(text);
|
|
1169
|
+
}
|
|
1168
1170
|
function isActiveTurnInterrupted() {
|
|
1169
1171
|
const turnKey = session.activeInput?.turnKey;
|
|
1170
1172
|
return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
|
|
1171
1173
|
}
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1174
|
+
function clearStreaming() {
|
|
1175
|
+
return streamingOutput.clear().catch(() => { });
|
|
1176
|
+
}
|
|
1177
|
+
function getFinalTurnTrail() {
|
|
1178
|
+
const turnId = session.currentTurnId ?? `claude-${conversationId}`;
|
|
1179
|
+
return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
|
|
1180
|
+
}
|
|
1181
|
+
function clearIdleResetTimer() {
|
|
1182
|
+
if (!session.idleResetTimer)
|
|
1183
|
+
return;
|
|
1184
|
+
clearTimeout(session.idleResetTimer);
|
|
1185
|
+
session.idleResetTimer = null;
|
|
1186
|
+
}
|
|
1187
|
+
function clearFinalDeliveryTimer() {
|
|
1188
|
+
if (!session.finalDeliveryTimer)
|
|
1189
|
+
return;
|
|
1190
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
1191
|
+
session.finalDeliveryTimer = null;
|
|
1192
|
+
}
|
|
1193
|
+
function resetTurnToIdle() {
|
|
1194
|
+
if (session.closed)
|
|
1195
|
+
return;
|
|
1196
|
+
clearFinalDeliveryTimer();
|
|
1197
|
+
resetClaudeCompletedTurnState(session);
|
|
1198
|
+
stopVisibleWorkSignal();
|
|
1199
|
+
writeState();
|
|
1200
|
+
writeTurn();
|
|
1201
|
+
drainPendingInput();
|
|
1202
|
+
}
|
|
1203
|
+
function scheduleFinalHandoffReset() {
|
|
1204
|
+
clearIdleResetTimer();
|
|
1205
|
+
session.idleResetTimer = setTimeout(() => {
|
|
1206
|
+
session.idleResetTimer = null;
|
|
1207
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
1208
|
+
clearStreaming();
|
|
1209
|
+
resetTurnToIdle();
|
|
1210
|
+
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
1211
|
+
}
|
|
1212
|
+
function markPendingFinalDelivery(finalText, turn) {
|
|
1213
|
+
clearIdleResetTimer();
|
|
1214
|
+
const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
|
|
1215
|
+
? session.pendingFinalDelivery.retryCount
|
|
1216
|
+
: 0;
|
|
1217
|
+
session.pendingFinalText = finalText;
|
|
1218
|
+
session.pendingFinalDelivery = {
|
|
1219
|
+
turnKey: turn.turnKey,
|
|
1220
|
+
text: finalText,
|
|
1221
|
+
retryCount,
|
|
1222
|
+
messageId: buildClaudeFinalMessageId({
|
|
1223
|
+
agentId,
|
|
1224
|
+
conversationId,
|
|
1225
|
+
turnKey: turn.turnKey,
|
|
1226
|
+
}),
|
|
1227
|
+
};
|
|
1228
|
+
session.state.state = 'running';
|
|
1229
|
+
session.turnState = 'streaming';
|
|
1230
|
+
stopVisibleWorkSignal();
|
|
1231
|
+
streamingOutput.replaceSnapshot(finalText, 'streaming').catch(() => { });
|
|
1232
|
+
writeState();
|
|
1233
|
+
writeTurn();
|
|
1234
|
+
typingSignals.start(conversationId, 'typing').catch(() => { });
|
|
1235
|
+
}
|
|
1236
|
+
async function deliverFinalReply(finalText, turn = session.activeInput) {
|
|
1237
|
+
if (!shouldDeliverClaudeFinal({
|
|
1206
1238
|
turn,
|
|
1207
|
-
finalizedTurnKeys:
|
|
1208
|
-
interruptedTurnKeys:
|
|
1209
|
-
})
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
const uploaded = await port.uploadMedia({
|
|
1218
|
-
path: file.path,
|
|
1219
|
-
mime: inferUploadMimeType(file.path),
|
|
1220
|
-
fileName: file.fileName,
|
|
1239
|
+
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
1240
|
+
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
1241
|
+
})) {
|
|
1242
|
+
return true;
|
|
1243
|
+
}
|
|
1244
|
+
const deliverTurn = turn;
|
|
1245
|
+
if (!deliverTurn)
|
|
1246
|
+
return true;
|
|
1247
|
+
const finalMessageId = buildClaudeFinalMessageId({
|
|
1248
|
+
agentId,
|
|
1221
1249
|
conversationId,
|
|
1250
|
+
turnKey: deliverTurn.turnKey,
|
|
1222
1251
|
});
|
|
1223
|
-
|
|
1224
|
-
conversationId,
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1252
|
+
try {
|
|
1253
|
+
await sendMessageWithRetry(client, conversationId, finalText, {
|
|
1254
|
+
messageId: finalMessageId,
|
|
1255
|
+
...(session.activeSelfContextId
|
|
1256
|
+
? { selfContextId: session.activeSelfContextId }
|
|
1257
|
+
: {}),
|
|
1258
|
+
metadata: {
|
|
1259
|
+
turnId: session.currentTurnId,
|
|
1260
|
+
turnKey: deliverTurn.turnKey,
|
|
1261
|
+
sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
|
|
1262
|
+
turnSemantics: 'turn_complete',
|
|
1263
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1264
|
+
...(getFinalTurnTrail().length > 0
|
|
1265
|
+
? { turnTrail: getFinalTurnTrail() }
|
|
1266
|
+
: {}),
|
|
1267
|
+
},
|
|
1268
|
+
});
|
|
1269
|
+
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1270
|
+
session.state.state = 'idle';
|
|
1271
|
+
session.pendingFinalDelivery = null;
|
|
1272
|
+
clearFinalDeliveryTimer();
|
|
1273
|
+
writeState();
|
|
1274
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
catch (err) {
|
|
1278
|
+
markPendingFinalDelivery(finalText, deliverTurn);
|
|
1279
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
function sendTurnArtifactFile(file) {
|
|
1284
|
+
return sendMediaFileMessage(client, conversationId, file.path, '', {
|
|
1228
1285
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1229
1286
|
metadata: buildArtifactMediaMetadata(),
|
|
1230
1287
|
});
|
|
@@ -1261,6 +1318,145 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1261
1318
|
await routeWorkspaceGeneratedArtifacts(input);
|
|
1262
1319
|
}
|
|
1263
1320
|
}
|
|
1321
|
+
function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
|
|
1322
|
+
if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
|
|
1323
|
+
return;
|
|
1324
|
+
if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
|
|
1325
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
|
|
1326
|
+
session.pendingFinalDelivery = null;
|
|
1327
|
+
resetTurnToIdle();
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
pending.retryCount += 1;
|
|
1331
|
+
session.pendingFinalDelivery = pending;
|
|
1332
|
+
clearFinalDeliveryTimer();
|
|
1333
|
+
session.finalDeliveryTimer = setTimeout(() => {
|
|
1334
|
+
session.finalDeliveryTimer = null;
|
|
1335
|
+
const retryTurn = session.activeInput?.turnKey === pending.turnKey
|
|
1336
|
+
? session.activeInput
|
|
1337
|
+
: null;
|
|
1338
|
+
void deliverFinalReply(pending.text, retryTurn).then((sent) => {
|
|
1339
|
+
if (sent) {
|
|
1340
|
+
scheduleFinalHandoffReset();
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
scheduleFinalDeliveryRetry();
|
|
1344
|
+
});
|
|
1345
|
+
}, FINAL_DELIVERY_RETRY_MS);
|
|
1346
|
+
}
|
|
1347
|
+
function writeState() {
|
|
1348
|
+
const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
|
|
1349
|
+
? session.runtimeControlErrors
|
|
1350
|
+
: null;
|
|
1351
|
+
const controlState = {};
|
|
1352
|
+
const appliedAt = Date.now();
|
|
1353
|
+
if (session.state.model !== undefined) {
|
|
1354
|
+
controlState.model = { value: session.state.model, source: 'applied', appliedAt };
|
|
1355
|
+
}
|
|
1356
|
+
else if (config.model !== undefined) {
|
|
1357
|
+
controlState.model = { value: config.model, source: 'requested' };
|
|
1358
|
+
}
|
|
1359
|
+
if (session.state.permissionMode !== undefined) {
|
|
1360
|
+
controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
|
|
1361
|
+
}
|
|
1362
|
+
else if (config.permissionMode !== undefined) {
|
|
1363
|
+
controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
|
|
1364
|
+
}
|
|
1365
|
+
if (session.state.effort !== undefined) {
|
|
1366
|
+
controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
|
|
1367
|
+
}
|
|
1368
|
+
else if (config.effort !== undefined) {
|
|
1369
|
+
controlState.effort = { value: config.effort, source: 'requested' };
|
|
1370
|
+
}
|
|
1371
|
+
if (session.state.ultracode !== undefined) {
|
|
1372
|
+
controlState.ultracode = { value: session.state.ultracode, source: 'applied', appliedAt };
|
|
1373
|
+
}
|
|
1374
|
+
else if (config.ultracode !== undefined) {
|
|
1375
|
+
controlState.ultracode = { value: config.ultracode, source: 'requested' };
|
|
1376
|
+
}
|
|
1377
|
+
runtimeState.writeSessionState(conversationId, {
|
|
1378
|
+
model: session.state.model,
|
|
1379
|
+
permissionMode: session.state.permissionMode,
|
|
1380
|
+
effort: session.state.effort,
|
|
1381
|
+
...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
|
|
1382
|
+
controlState,
|
|
1383
|
+
runtimeControlErrors,
|
|
1384
|
+
contextUsage: session.state.contextUsage,
|
|
1385
|
+
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
1386
|
+
cwd,
|
|
1387
|
+
executionMode: session.environment.mode,
|
|
1388
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
1389
|
+
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
1390
|
+
...(resolveExecutionFallbackReason(session.environment)
|
|
1391
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
1392
|
+
: {}),
|
|
1393
|
+
hostMode: true,
|
|
1394
|
+
isActive: true,
|
|
1395
|
+
}).catch(() => { });
|
|
1396
|
+
}
|
|
1397
|
+
function setRuntimeControlError(controlId, value, error) {
|
|
1398
|
+
session.runtimeControlErrors = {
|
|
1399
|
+
...session.runtimeControlErrors,
|
|
1400
|
+
[controlId]: {
|
|
1401
|
+
value,
|
|
1402
|
+
message: formatClaudeControlError(error),
|
|
1403
|
+
updatedAt: Date.now(),
|
|
1404
|
+
},
|
|
1405
|
+
};
|
|
1406
|
+
writeState();
|
|
1407
|
+
}
|
|
1408
|
+
function clearRuntimeControlError(controlId) {
|
|
1409
|
+
if (!(controlId in session.runtimeControlErrors))
|
|
1410
|
+
return;
|
|
1411
|
+
const next = { ...session.runtimeControlErrors };
|
|
1412
|
+
delete next[controlId];
|
|
1413
|
+
session.runtimeControlErrors = next;
|
|
1414
|
+
writeState();
|
|
1415
|
+
}
|
|
1416
|
+
function writeTurn() {
|
|
1417
|
+
const isOpenTurn = session.turnState === 'thinking'
|
|
1418
|
+
|| session.turnState === 'streaming'
|
|
1419
|
+
|| session.turnState === 'tool'
|
|
1420
|
+
|| session.turnState === 'waiting_input';
|
|
1421
|
+
if (isOpenTurn) {
|
|
1422
|
+
session.currentTurnUpdatedAt = Date.now();
|
|
1423
|
+
}
|
|
1424
|
+
runtimeState.writeTurnState(conversationId, {
|
|
1425
|
+
turnId: session.currentTurnId,
|
|
1426
|
+
state: session.turnState,
|
|
1427
|
+
queueDepth: session.pendingInputs.length,
|
|
1428
|
+
currentSpeakerId: agentId,
|
|
1429
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
1430
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1431
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
1432
|
+
...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
|
|
1433
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
1434
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
1435
|
+
: {}),
|
|
1436
|
+
}).catch(() => { });
|
|
1437
|
+
}
|
|
1438
|
+
function refreshVisibleWorkSignal() {
|
|
1439
|
+
if (session.closed)
|
|
1440
|
+
return;
|
|
1441
|
+
if (session.turnState !== 'thinking' && session.turnState !== 'tool')
|
|
1442
|
+
return;
|
|
1443
|
+
typingSignals.start(conversationId, 'thinking').catch(() => { });
|
|
1444
|
+
}
|
|
1445
|
+
function startVisibleWorkSignal() {
|
|
1446
|
+
refreshVisibleWorkSignal();
|
|
1447
|
+
}
|
|
1448
|
+
function stopVisibleWorkSignal() {
|
|
1449
|
+
if (session.typingKeepaliveTimer) {
|
|
1450
|
+
clearInterval(session.typingKeepaliveTimer);
|
|
1451
|
+
session.typingKeepaliveTimer = null;
|
|
1452
|
+
}
|
|
1453
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
1454
|
+
}
|
|
1455
|
+
async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
|
|
1456
|
+
if (!markAccepted || !sourceMessageId)
|
|
1457
|
+
return;
|
|
1458
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
1459
|
+
}
|
|
1264
1460
|
async function prepareArtifactsForInput(input) {
|
|
1265
1461
|
if (input.kind !== 'canon')
|
|
1266
1462
|
return;
|
|
@@ -1276,8 +1472,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1276
1472
|
return;
|
|
1277
1473
|
}
|
|
1278
1474
|
}
|
|
1475
|
+
async function startCanonInput(input) {
|
|
1476
|
+
await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
|
|
1477
|
+
await prepareArtifactsForInput(input);
|
|
1478
|
+
session.state.state = 'running';
|
|
1479
|
+
sendInput(input);
|
|
1480
|
+
}
|
|
1481
|
+
function drainPendingInput() {
|
|
1482
|
+
if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
|
|
1483
|
+
return;
|
|
1484
|
+
const next = session.pendingInputs.shift();
|
|
1485
|
+
if (!next)
|
|
1486
|
+
return;
|
|
1487
|
+
session.lastAcceptedIntent = next.intent;
|
|
1488
|
+
writeTurn();
|
|
1489
|
+
void startCanonInput(next);
|
|
1490
|
+
}
|
|
1279
1491
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
1280
|
-
|
|
1492
|
+
session.lastActivity = Date.now();
|
|
1493
|
+
const input = createClaudeInputEnvelope({
|
|
1281
1494
|
kind: 'canon',
|
|
1282
1495
|
msg,
|
|
1283
1496
|
intent,
|
|
@@ -1285,11 +1498,35 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1285
1498
|
markAccepted,
|
|
1286
1499
|
isOwnerTurn,
|
|
1287
1500
|
artifactRoutingMode,
|
|
1288
|
-
})
|
|
1501
|
+
});
|
|
1502
|
+
if (session.pendingFinalDelivery) {
|
|
1503
|
+
session.pendingInputs.push(input);
|
|
1504
|
+
writeTurn();
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (session.state.state === 'running' && intent !== 'interrupt') {
|
|
1508
|
+
session.pendingInputs.push(input);
|
|
1509
|
+
writeTurn();
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
1513
|
+
if (session.activeInput) {
|
|
1514
|
+
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
1515
|
+
}
|
|
1516
|
+
session.pendingInputs.unshift(input);
|
|
1517
|
+
session.turnState = 'interrupted';
|
|
1518
|
+
stopVisibleWorkSignal();
|
|
1519
|
+
clearStreaming().catch(() => { });
|
|
1520
|
+
writeTurn();
|
|
1521
|
+
void session.query.interrupt().catch(() => { });
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
session.lastAcceptedIntent = intent;
|
|
1525
|
+
writeTurn();
|
|
1526
|
+
void startCanonInput(input);
|
|
1289
1527
|
};
|
|
1290
1528
|
writeTurn();
|
|
1291
1529
|
// ── Process SDK output messages ──
|
|
1292
|
-
let assistantStreamOrdinal = 0;
|
|
1293
1530
|
const processMessages = async () => {
|
|
1294
1531
|
try {
|
|
1295
1532
|
for await (const msg of q) {
|
|
@@ -1320,10 +1557,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1320
1557
|
stopVisibleWorkSignal();
|
|
1321
1558
|
break;
|
|
1322
1559
|
}
|
|
1323
|
-
if (event?.type === '
|
|
1324
|
-
assistantStreamOrdinal += 1;
|
|
1325
|
-
}
|
|
1326
|
-
else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
1560
|
+
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
1327
1561
|
session.toolInProgress = true;
|
|
1328
1562
|
session.turnState = 'tool';
|
|
1329
1563
|
streamingOutput.addBlock({
|
|
@@ -1352,35 +1586,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1352
1586
|
stopVisibleWorkSignal();
|
|
1353
1587
|
writeTurn();
|
|
1354
1588
|
const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
|
|
1355
|
-
|
|
1356
|
-
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
|
|
1589
|
+
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
|
|
1357
1590
|
}
|
|
1358
1591
|
break;
|
|
1359
1592
|
}
|
|
1360
1593
|
case 'system': {
|
|
1361
1594
|
const subtype = msg.subtype;
|
|
1362
|
-
if (subtype === 'task_started'
|
|
1363
|
-
|| subtype === 'task_progress'
|
|
1364
|
-
|| subtype === 'task_updated'
|
|
1365
|
-
|| subtype === 'task_notification') {
|
|
1366
|
-
const activity = mapClaudeSubagentActivity(msg);
|
|
1367
|
-
if (activity && !isActiveTurnInterrupted()) {
|
|
1368
|
-
if (!session.currentTurnId) {
|
|
1369
|
-
session.currentTurnId = randomUUID();
|
|
1370
|
-
session.currentTurnOpenedAt = Date.now();
|
|
1371
|
-
}
|
|
1372
|
-
if (session.state.state === 'running' && session.turnState !== 'streaming') {
|
|
1373
|
-
session.turnState = 'thinking';
|
|
1374
|
-
startVisibleWorkSignal();
|
|
1375
|
-
writeTurn();
|
|
1376
|
-
}
|
|
1377
|
-
await streamingOutput.addBlock(activity).catch(() => { });
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
if (subtype === 'model_refusal_fallback') {
|
|
1381
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
|
|
1382
|
-
+ `${msg.original_model} → ${msg.fallback_model}`);
|
|
1383
|
-
}
|
|
1384
1595
|
if (subtype === 'session_state_changed') {
|
|
1385
1596
|
const state = msg.state;
|
|
1386
1597
|
session.state.state = state;
|
|
@@ -1392,7 +1603,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1392
1603
|
}
|
|
1393
1604
|
session.state.contextUsage = undefined;
|
|
1394
1605
|
session.pendingFinalText = null;
|
|
1395
|
-
assistantStreamOrdinal = 0;
|
|
1396
1606
|
session.toolInProgress = false;
|
|
1397
1607
|
session.turnState = 'thinking';
|
|
1398
1608
|
startVisibleWorkSignal();
|
|
@@ -1489,6 +1699,33 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1489
1699
|
const init = async () => {
|
|
1490
1700
|
processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
|
|
1491
1701
|
await q.initializationResult();
|
|
1702
|
+
if (config.model) {
|
|
1703
|
+
try {
|
|
1704
|
+
await q.setModel(config.model);
|
|
1705
|
+
session.state.model = config.model;
|
|
1706
|
+
}
|
|
1707
|
+
catch (err) {
|
|
1708
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
if (config.effort) {
|
|
1712
|
+
try {
|
|
1713
|
+
await applyClaudeEffortLevel(q, config.effort);
|
|
1714
|
+
session.state.effort = config.effort;
|
|
1715
|
+
}
|
|
1716
|
+
catch (err) {
|
|
1717
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
if (config.ultracode) {
|
|
1721
|
+
try {
|
|
1722
|
+
await applyClaudeUltracode(q, config.ultracode === 'on');
|
|
1723
|
+
session.state.ultracode = config.ultracode;
|
|
1724
|
+
}
|
|
1725
|
+
catch (err) {
|
|
1726
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial ultracode:`, err);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1492
1729
|
if (!resumeSessionId) {
|
|
1493
1730
|
// New session — send seed message to activate streaming input mode
|
|
1494
1731
|
sendInput(createClaudeInputEnvelope({
|
|
@@ -1503,32 +1740,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1503
1740
|
},
|
|
1504
1741
|
}));
|
|
1505
1742
|
}
|
|
1506
|
-
// Refresh the canonical runtime descriptor
|
|
1507
|
-
// session may be initialized with a selected model, which can narrow the
|
|
1508
|
-
// SDK's cached model list.
|
|
1743
|
+
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1509
1744
|
try {
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
if (discoveredModels.length > 0) {
|
|
1515
|
-
runtimeModels = discoveredModels;
|
|
1516
|
-
}
|
|
1517
|
-
const modelList = resolveClaudeModelOptions({
|
|
1518
|
-
discovered: runtimeModels,
|
|
1745
|
+
const models = await q.supportedModels();
|
|
1746
|
+
const modelList = mergeClaudeModelOptions({
|
|
1747
|
+
previous: runtimeModels,
|
|
1748
|
+
next: toModelOptions(models),
|
|
1519
1749
|
selectedModel: session.state.model,
|
|
1520
1750
|
});
|
|
1521
1751
|
session.availableModels = modelList;
|
|
1752
|
+
runtimeModels = modelList;
|
|
1522
1753
|
onRuntimeDescriptorUpdate({
|
|
1523
1754
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1524
|
-
defaultModel:
|
|
1755
|
+
defaultModel: modelList[0]?.value,
|
|
1525
1756
|
defaultPermissionMode: 'default',
|
|
1526
1757
|
availableModels: modelList,
|
|
1527
1758
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1528
1759
|
availableExecutionModes: config.availableExecutionModes,
|
|
1529
1760
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1530
1761
|
models: modelList,
|
|
1531
|
-
defaults: config.sessionSettings,
|
|
1532
1762
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1533
1763
|
workspaceRoots: workspaceRootMetadata,
|
|
1534
1764
|
executionModes: config.availableExecutionModes,
|
|
@@ -1536,7 +1766,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1536
1766
|
}),
|
|
1537
1767
|
});
|
|
1538
1768
|
writeState();
|
|
1539
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${
|
|
1769
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
|
|
1540
1770
|
}
|
|
1541
1771
|
catch {
|
|
1542
1772
|
writeState();
|
|
@@ -1581,57 +1811,41 @@ export async function main() {
|
|
|
1581
1811
|
for (const warning of workspaceDiscovery.warnings) {
|
|
1582
1812
|
console.error(`[canon-host] ${warning}`);
|
|
1583
1813
|
}
|
|
1584
|
-
const { agentName: profileAgentName, profile,
|
|
1585
|
-
const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
|
|
1586
|
-
activeLockHandle = lockHandle ?? null;
|
|
1814
|
+
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
|
|
1587
1815
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
1588
|
-
// ── Canon
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
'ready',
|
|
1596
|
-
'connectionState',
|
|
1597
|
-
'onMessage',
|
|
1598
|
-
'onMessageDeleted',
|
|
1599
|
-
'onConversationUpdated',
|
|
1600
|
-
'onSessionControl',
|
|
1601
|
-
'onControlSignal',
|
|
1602
|
-
'onControlReply',
|
|
1603
|
-
]);
|
|
1604
|
-
const bridge = await connectBridge({
|
|
1605
|
-
...(profile ? { profile } : {}),
|
|
1606
|
-
mode: 'auto',
|
|
1607
|
-
...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
|
|
1608
|
-
hello: {
|
|
1609
|
-
clientType: 'claude-code',
|
|
1610
|
-
wantFamilies: ['messages', 'runtime_turn'],
|
|
1611
|
-
},
|
|
1612
|
-
configure: (bridgeClient) => notifications.connect(bridgeClient),
|
|
1613
|
-
});
|
|
1614
|
-
// The BridgeClient's zod-derived result shapes mirror the core wire types
|
|
1615
|
-
// structurally; the port pins the strong types the host machinery uses.
|
|
1616
|
-
const port = bridge.client;
|
|
1617
|
-
bridge.client.onProtocolError((error) => {
|
|
1618
|
-
console.error(`[canon-host] Bridge protocol error: ${error.message}`);
|
|
1816
|
+
// ── Canon API client + RTDB auth ──
|
|
1817
|
+
const client = new CanonClient(apiKey, baseUrl);
|
|
1818
|
+
const rtdb = initRTDBAuth(client);
|
|
1819
|
+
const typingSignals = createTypingStatusPublisher({
|
|
1820
|
+
setTyping: (conversationId, typing, status) => status
|
|
1821
|
+
? client.setTyping(conversationId, typing, status)
|
|
1822
|
+
: client.setTyping(conversationId, typing),
|
|
1619
1823
|
});
|
|
1620
|
-
const typingSignals = createPortTypingSignals(port);
|
|
1621
1824
|
async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
|
|
1622
1825
|
if (!markAccepted || !sourceMessageId)
|
|
1623
1826
|
return;
|
|
1624
|
-
await
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1827
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
1828
|
+
}
|
|
1829
|
+
let agentId;
|
|
1830
|
+
let ownerId = null;
|
|
1831
|
+
let ownerName = null;
|
|
1832
|
+
try {
|
|
1833
|
+
const ctx = await client.getAgentMe();
|
|
1834
|
+
agentId = ctx.agentId;
|
|
1835
|
+
ownerId = ctx.ownerId;
|
|
1836
|
+
ownerName = ctx.ownerName;
|
|
1837
|
+
console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
|
|
1838
|
+
}
|
|
1839
|
+
catch {
|
|
1840
|
+
if (profileAgentId) {
|
|
1841
|
+
agentId = profileAgentId;
|
|
1842
|
+
}
|
|
1843
|
+
else {
|
|
1844
|
+
const auth = await client.getAuthToken();
|
|
1845
|
+
agentId = auth.agentId;
|
|
1846
|
+
}
|
|
1847
|
+
console.error(`[canon-host] Authenticated as ${agentId}`);
|
|
1629
1848
|
}
|
|
1630
|
-
const agentId = bridge.hello.agentId;
|
|
1631
|
-
const ownerId = bridge.hello.agentContext.ownerId ?? null;
|
|
1632
|
-
const ownerName = bridge.hello.agentContext.ownerName ?? null;
|
|
1633
|
-
console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
|
|
1634
|
-
+ `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
|
|
1635
1849
|
const launchArgs = [...process.argv.slice(2)];
|
|
1636
1850
|
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
1637
1851
|
launchArgs.push('--cwd', workingDir);
|
|
@@ -1660,6 +1874,11 @@ export async function main() {
|
|
|
1660
1874
|
lastStartedAt: new Date().toISOString(),
|
|
1661
1875
|
lastHeartbeatAt: new Date().toISOString(),
|
|
1662
1876
|
});
|
|
1877
|
+
const runtimeState = createRuntimeStatePublisher({
|
|
1878
|
+
agentId,
|
|
1879
|
+
clientType: 'claude-code',
|
|
1880
|
+
hostMode: true,
|
|
1881
|
+
});
|
|
1663
1882
|
let streamConnected = false;
|
|
1664
1883
|
const hostAvailableExecutionModes = [
|
|
1665
1884
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1672,40 +1891,39 @@ export async function main() {
|
|
|
1672
1891
|
});
|
|
1673
1892
|
let runtimeDescriptor = {
|
|
1674
1893
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1675
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1676
1894
|
defaultPermissionMode: 'default',
|
|
1677
1895
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1678
1896
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1679
1897
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1680
1898
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1681
1899
|
models: runtimeModels,
|
|
1682
|
-
defaults: profileSessionDefaults,
|
|
1683
1900
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1684
1901
|
workspaceRoots: workspaceRootMetadata,
|
|
1685
1902
|
executionModes: hostAvailableExecutionModes,
|
|
1686
1903
|
presentation: runtimePresentation,
|
|
1687
1904
|
}),
|
|
1688
1905
|
};
|
|
1689
|
-
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
if (!streamConnected ||
|
|
1906
|
+
const conversationCache = new Map();
|
|
1907
|
+
const knownConversationIds = new Set();
|
|
1908
|
+
const promptedGroupContextConversationIds = new Set();
|
|
1909
|
+
const pendingMembershipChanges = new Map();
|
|
1910
|
+
let lastKnownConversationRefreshAt = 0;
|
|
1911
|
+
// Publish the app-subscribed /agent-session snapshot for the given
|
|
1912
|
+
// conversations. Extracted from the heartbeat so control handlers can converge
|
|
1913
|
+
// the app immediately instead of waiting for the next 30s heartbeat
|
|
1914
|
+
// (session-control lag fix). Hoisted so it is reachable from earlier code.
|
|
1915
|
+
async function publishSessionSnapshots(conversationIds) {
|
|
1916
|
+
if (!streamConnected || conversationIds.length === 0)
|
|
1700
1917
|
return;
|
|
1701
|
-
await
|
|
1702
|
-
conversationIds
|
|
1918
|
+
await publishHostSessionSnapshots({
|
|
1919
|
+
conversationIds,
|
|
1920
|
+
agentId,
|
|
1703
1921
|
clientType: 'claude-code',
|
|
1704
1922
|
runtime: runtimeDescriptor,
|
|
1705
|
-
workspaceOptions
|
|
1923
|
+
workspaceOptions,
|
|
1706
1924
|
defaultCwd: workingDir,
|
|
1707
1925
|
extraSessionConfigFields: ['permissionMode', 'effort'],
|
|
1708
|
-
liveSessionConfigByConversation:
|
|
1926
|
+
liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
|
|
1709
1927
|
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
1710
1928
|
return [
|
|
1711
1929
|
session.conversationId,
|
|
@@ -1713,9 +1931,7 @@ export async function main() {
|
|
|
1713
1931
|
...(session.state.model ? { model: session.state.model } : {}),
|
|
1714
1932
|
...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
|
|
1715
1933
|
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
1716
|
-
...(session.state.runtimeControlValues
|
|
1717
|
-
? { runtimeControlValues: session.state.runtimeControlValues }
|
|
1718
|
-
: {}),
|
|
1934
|
+
...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
|
|
1719
1935
|
...(workspaceId ? { workspaceId } : {}),
|
|
1720
1936
|
executionMode: session.environment.mode,
|
|
1721
1937
|
executionBranch: session.environment.branch ?? null,
|
|
@@ -1725,7 +1941,7 @@ export async function main() {
|
|
|
1725
1941
|
}).catch((error) => {
|
|
1726
1942
|
console.error('[canon-host] Failed to publish session snapshots:', error);
|
|
1727
1943
|
});
|
|
1728
|
-
}
|
|
1944
|
+
}
|
|
1729
1945
|
const publishRuntimeHeartbeat = async () => {
|
|
1730
1946
|
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
1731
1947
|
agentId,
|
|
@@ -1746,20 +1962,18 @@ export async function main() {
|
|
|
1746
1962
|
console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
|
|
1747
1963
|
return runtimeMetadata;
|
|
1748
1964
|
});
|
|
1749
|
-
await publishAgentRuntime(
|
|
1965
|
+
await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
|
|
1750
1966
|
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
1751
1967
|
});
|
|
1752
|
-
await
|
|
1968
|
+
await publishSessionSnapshots(Array.from(knownConversationIds));
|
|
1753
1969
|
// Rebuild so the descriptor picks up native slash commands from the
|
|
1754
1970
|
// metadata refresh above — the initial descriptor is built before the
|
|
1755
1971
|
// command inventory has loaded.
|
|
1756
1972
|
if (runtimeDescriptor.runtimeDescriptor) {
|
|
1757
1973
|
runtimeDescriptor = {
|
|
1758
1974
|
...runtimeDescriptor,
|
|
1759
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1760
1975
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1761
1976
|
models: runtimeModels,
|
|
1762
|
-
defaults: profileSessionDefaults,
|
|
1763
1977
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1764
1978
|
workspaceRoots: workspaceRootMetadata,
|
|
1765
1979
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1787,15 +2001,8 @@ export async function main() {
|
|
|
1787
2001
|
executionBranch: session?.environment.branch ?? null,
|
|
1788
2002
|
worktreePath: session?.environment.worktreePath ?? null,
|
|
1789
2003
|
fallbackReason: resolveExecutionFallbackReason(session?.environment),
|
|
1790
|
-
fallbackModel: session?.state.fallbackModel
|
|
1791
|
-
?? profileSessionDefaults.fallbackModel
|
|
1792
|
-
?? null,
|
|
1793
|
-
ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
|
|
1794
|
-
});
|
|
1795
|
-
await port.publishRuntimeStatus({
|
|
1796
|
-
conversationId,
|
|
1797
|
-
presentation: payload,
|
|
1798
2004
|
});
|
|
2005
|
+
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
1799
2006
|
})).catch((error) => {
|
|
1800
2007
|
console.error('[canon-host] Failed to publish runtime info:', error);
|
|
1801
2008
|
});
|
|
@@ -1805,7 +2012,7 @@ export async function main() {
|
|
|
1805
2012
|
runtimeModels = await detectRuntimeModels(workingDir);
|
|
1806
2013
|
runtimeDescriptor = {
|
|
1807
2014
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1808
|
-
defaultModel:
|
|
2015
|
+
defaultModel: runtimeModels[0]?.value,
|
|
1809
2016
|
defaultPermissionMode: 'default',
|
|
1810
2017
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1811
2018
|
availableModels: runtimeModels,
|
|
@@ -1813,7 +2020,6 @@ export async function main() {
|
|
|
1813
2020
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1814
2021
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1815
2022
|
models: runtimeModels,
|
|
1816
|
-
defaults: profileSessionDefaults,
|
|
1817
2023
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1818
2024
|
workspaceRoots: workspaceRootMetadata,
|
|
1819
2025
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1824,14 +2030,12 @@ export async function main() {
|
|
|
1824
2030
|
catch {
|
|
1825
2031
|
runtimeDescriptor = {
|
|
1826
2032
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1827
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1828
2033
|
defaultPermissionMode: 'default',
|
|
1829
2034
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1830
2035
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1831
2036
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1832
2037
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1833
2038
|
models: runtimeModels,
|
|
1834
|
-
defaults: profileSessionDefaults,
|
|
1835
2039
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1836
2040
|
workspaceRoots: workspaceRootMetadata,
|
|
1837
2041
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1840,12 +2044,14 @@ export async function main() {
|
|
|
1840
2044
|
};
|
|
1841
2045
|
}
|
|
1842
2046
|
try {
|
|
1843
|
-
const convos = await
|
|
1844
|
-
|
|
2047
|
+
const convos = await client.getConversations();
|
|
2048
|
+
lastKnownConversationRefreshAt = Date.now();
|
|
1845
2049
|
for (const conversation of convos) {
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
2050
|
+
knownConversationIds.add(conversation.id);
|
|
2051
|
+
conversationCache.set(conversation.id, conversation);
|
|
2052
|
+
runtimeState.clearStreaming(conversation.id).catch(() => { });
|
|
2053
|
+
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
2054
|
+
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
1849
2055
|
typingSignals.clear(conversation.id).catch(() => { });
|
|
1850
2056
|
}
|
|
1851
2057
|
console.error(`[canon-host] ${convos.length} conversations`);
|
|
@@ -1853,68 +2059,64 @@ export async function main() {
|
|
|
1853
2059
|
catch {
|
|
1854
2060
|
console.error('[canon-host] Could not load conversations');
|
|
1855
2061
|
}
|
|
1856
|
-
// ── Session manager
|
|
1857
|
-
const
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
onShutdown: async () => {
|
|
1880
|
-
detachControlNotifications();
|
|
1881
|
-
notifications.detach();
|
|
1882
|
-
approvalManager?.dispose();
|
|
1883
|
-
await port.clearAgentRuntime().catch(() => { });
|
|
1884
|
-
bridge.client.close();
|
|
2062
|
+
// ── Session manager ──
|
|
2063
|
+
const sessions = new Map();
|
|
2064
|
+
const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
|
|
2065
|
+
const pendingSessionCreations = new Map();
|
|
2066
|
+
// Shared /control channel poller (claude host profile): session + signal
|
|
2067
|
+
// keys, sequential conversations, active/idle cadence + jitter sampled at
|
|
2068
|
+
// cycle start, eager baselines at session creation, and nodes left in place
|
|
2069
|
+
// when a handler throws (consumeOnError defaults to false for both keys).
|
|
2070
|
+
const controlPoller = new ControlChannelPoller({
|
|
2071
|
+
rtdb,
|
|
2072
|
+
agentId,
|
|
2073
|
+
conversationIds: () => sessions.keys(),
|
|
2074
|
+
cadence: {
|
|
2075
|
+
kind: 'adaptive',
|
|
2076
|
+
activeMs: CONTROL_POLL_MS,
|
|
2077
|
+
idleMs: IDLE_CONTROL_POLL_MS,
|
|
2078
|
+
jitterMs: CONTROL_POLL_JITTER_MS,
|
|
2079
|
+
hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
|
|
2080
|
+
&& (session.state.state === 'running'
|
|
2081
|
+
|| session.state.state === 'requires_action'
|
|
2082
|
+
|| session.pendingInputs.length > 0
|
|
2083
|
+
|| Boolean(session.pendingFinalDelivery))),
|
|
2084
|
+
activitySample: 'cycle-start',
|
|
1885
2085
|
},
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2086
|
+
pollOnStart: true,
|
|
2087
|
+
conversationConcurrency: 'sequential',
|
|
2088
|
+
handlers: {
|
|
2089
|
+
session: { handle: applyRuntimeSessionControl },
|
|
2090
|
+
signal: { handle: handleRuntimeControlSignal },
|
|
2091
|
+
},
|
|
2092
|
+
onError: ({ scope, key, conversationId, error }) => {
|
|
2093
|
+
if (scope === 'read')
|
|
2094
|
+
return; // Transient RTDB errors — retry next cycle
|
|
2095
|
+
const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
|
|
2096
|
+
console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
|
|
1889
2097
|
},
|
|
1890
|
-
});
|
|
1891
|
-
const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
|
|
1892
|
-
// Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
|
|
1893
|
-
const handleRuntimeControlSignal = createRuntimeSignalHandler({
|
|
1894
|
-
agentId,
|
|
1895
|
-
logPrefix: '[canon-host]',
|
|
1896
|
-
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1897
|
-
}, {
|
|
1898
|
-
sessions,
|
|
1899
|
-
port,
|
|
1900
|
-
isSessionRunning: (session) => session.state.state === 'running',
|
|
1901
|
-
interruptRuntime: (session) => session.query.interrupt(),
|
|
1902
|
-
resetSession: (session) => resetRuntimeSession(session),
|
|
1903
|
-
markSessionPendingInputsRejected,
|
|
1904
|
-
});
|
|
1905
|
-
// Control events arrive as bridge notifications — the bridge owns the ONE
|
|
1906
|
-
// authoritative /control consumer for this identity (plan §2); the host's
|
|
1907
|
-
// adaptive poller (active/idle cadence, jitter, per-session baselines) is
|
|
1908
|
-
// deleted with it.
|
|
1909
|
-
const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
|
|
1910
|
-
onSessionControl: applyRuntimeSessionControl,
|
|
1911
|
-
onControlSignal: handleRuntimeControlSignal,
|
|
1912
2098
|
});
|
|
1913
2099
|
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
1914
2100
|
const savedSessionIds = new Map();
|
|
2101
|
+
const { getConversationMeta } = createConversationMetadataLoader({
|
|
2102
|
+
client,
|
|
2103
|
+
conversationCache,
|
|
2104
|
+
});
|
|
1915
2105
|
function resolveWorkspaceIdForBaseCwd(baseCwd) {
|
|
1916
2106
|
return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
|
|
1917
2107
|
}
|
|
2108
|
+
async function refreshKnownConversationIds(force = false) {
|
|
2109
|
+
if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
const conversations = await client.getConversations();
|
|
2113
|
+
knownConversationIds.clear();
|
|
2114
|
+
for (const conversation of conversations) {
|
|
2115
|
+
knownConversationIds.add(conversation.id);
|
|
2116
|
+
conversationCache.set(conversation.id, conversation);
|
|
2117
|
+
}
|
|
2118
|
+
lastKnownConversationRefreshAt = Date.now();
|
|
2119
|
+
}
|
|
1918
2120
|
async function canRequestCanonApproval(conversationId) {
|
|
1919
2121
|
if (!ownerId)
|
|
1920
2122
|
return false;
|
|
@@ -1922,9 +2124,80 @@ export async function main() {
|
|
|
1922
2124
|
.catch(() => conversationCache.get(conversationId) ?? null);
|
|
1923
2125
|
return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
|
|
1924
2126
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
2127
|
+
function handleConversationUpdated(payload) {
|
|
2128
|
+
const rawMemberIds = payload.changes.memberIds;
|
|
2129
|
+
if (!Array.isArray(rawMemberIds))
|
|
2130
|
+
return;
|
|
2131
|
+
const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
|
|
2132
|
+
const cached = conversationCache.get(payload.conversationId);
|
|
2133
|
+
const membershipChange = payload.membershipChange
|
|
2134
|
+
?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
|
|
2135
|
+
if (cached) {
|
|
2136
|
+
conversationCache.set(payload.conversationId, {
|
|
2137
|
+
...cached,
|
|
2138
|
+
memberIds,
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
if (membershipChange) {
|
|
2142
|
+
pendingMembershipChanges.set(payload.conversationId, membershipChange);
|
|
2143
|
+
}
|
|
2144
|
+
if (!memberIds.includes(agentId)) {
|
|
2145
|
+
knownConversationIds.delete(payload.conversationId);
|
|
2146
|
+
conversationCache.delete(payload.conversationId);
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
function getGroupContextMode(conversationId, conversation) {
|
|
2150
|
+
if (conversation?.type !== 'group')
|
|
2151
|
+
return undefined;
|
|
2152
|
+
if (pendingMembershipChanges.has(conversationId))
|
|
2153
|
+
return 'membership_change';
|
|
2154
|
+
if (!promptedGroupContextConversationIds.has(conversationId))
|
|
2155
|
+
return 'initial';
|
|
2156
|
+
return undefined;
|
|
2157
|
+
}
|
|
2158
|
+
function markGroupContextModeUsed(conversationId, mode) {
|
|
2159
|
+
if (!mode)
|
|
2160
|
+
return;
|
|
2161
|
+
promptedGroupContextConversationIds.add(conversationId);
|
|
2162
|
+
if (mode === 'membership_change') {
|
|
2163
|
+
pendingMembershipChanges.delete(conversationId);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
async function loadHydratedInboundContext(input) {
|
|
2167
|
+
const [conversation, page] = await Promise.all([
|
|
2168
|
+
getConversationMeta(input.conversationId),
|
|
2169
|
+
input.hydratedPage
|
|
2170
|
+
? Promise.resolve(input.hydratedPage)
|
|
2171
|
+
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
2172
|
+
]);
|
|
2173
|
+
return buildHydratedInboundContext({
|
|
2174
|
+
agentId,
|
|
2175
|
+
conversationId: input.conversationId,
|
|
2176
|
+
conversation,
|
|
2177
|
+
page,
|
|
2178
|
+
activeSelfContextId: input.activeSelfContextId,
|
|
2179
|
+
selfContexts: input.selfContexts,
|
|
2180
|
+
provenance: input.provenance,
|
|
2181
|
+
message: input.message,
|
|
2182
|
+
senderName: input.senderName,
|
|
2183
|
+
isOwner: input.isOwner,
|
|
2184
|
+
ownerId,
|
|
2185
|
+
ownerName,
|
|
2186
|
+
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
2187
|
+
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
function closeSession(conversationId, options = {}) {
|
|
2191
|
+
const session = sessions.get(conversationId);
|
|
2192
|
+
if (!session)
|
|
2193
|
+
return;
|
|
2194
|
+
const saveResume = options.saveResume !== false;
|
|
2195
|
+
session.closed = true;
|
|
2196
|
+
if (session.typingKeepaliveTimer) {
|
|
2197
|
+
clearInterval(session.typingKeepaliveTimer);
|
|
2198
|
+
session.typingKeepaliveTimer = null;
|
|
2199
|
+
}
|
|
2200
|
+
releaseConversationEnvironment(session.environment);
|
|
1928
2201
|
const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
|
|
1929
2202
|
if (saveResume && session.sdkSessionId) {
|
|
1930
2203
|
savedSessionIds.set(resumeKey, session.sdkSessionId);
|
|
@@ -1956,15 +2229,89 @@ export async function main() {
|
|
|
1956
2229
|
});
|
|
1957
2230
|
}
|
|
1958
2231
|
}
|
|
2232
|
+
session.clearStreaming().catch(() => { });
|
|
2233
|
+
if (session.idleResetTimer) {
|
|
2234
|
+
clearTimeout(session.idleResetTimer);
|
|
2235
|
+
session.idleResetTimer = null;
|
|
2236
|
+
}
|
|
2237
|
+
if (session.finalDeliveryTimer) {
|
|
2238
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
2239
|
+
session.finalDeliveryTimer = null;
|
|
2240
|
+
}
|
|
2241
|
+
runtimeState.clearSessionState(conversationId).catch(() => { });
|
|
2242
|
+
runtimeState.clearTurnState(conversationId).catch(() => { });
|
|
2243
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
2244
|
+
typingSignals.dispose(conversationId);
|
|
2245
|
+
try {
|
|
2246
|
+
session.query.close();
|
|
2247
|
+
}
|
|
2248
|
+
catch { }
|
|
2249
|
+
sessions.delete(conversationId);
|
|
2250
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
|
|
1959
2251
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2252
|
+
async function resetRuntimeSession(session) {
|
|
2253
|
+
const conversationId = session.conversationId;
|
|
2254
|
+
const droppedInputs = session.pendingInputs.splice(0);
|
|
2255
|
+
await markSessionPendingInputsRejected(conversationId, droppedInputs);
|
|
2256
|
+
if (session.activeInput) {
|
|
2257
|
+
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2258
|
+
}
|
|
2259
|
+
session.pendingFinalText = null;
|
|
2260
|
+
session.pendingFinalDelivery = null;
|
|
2261
|
+
session.turnState = 'interrupted';
|
|
2262
|
+
session.clearStreaming().catch(() => { });
|
|
2263
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
2264
|
+
if (session.state.state === 'running') {
|
|
2265
|
+
await session.query.interrupt().catch(() => { });
|
|
2266
|
+
}
|
|
2267
|
+
closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
|
|
2268
|
+
}
|
|
2269
|
+
async function markSessionPendingInputsRejected(conversationId, inputs) {
|
|
2270
|
+
await Promise.all(inputs.map((input) => {
|
|
2271
|
+
if (!input.markAccepted || !input.sourceMessageId)
|
|
2272
|
+
return Promise.resolve();
|
|
2273
|
+
return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
|
|
2274
|
+
}));
|
|
2275
|
+
}
|
|
2276
|
+
function removeQueuedInput(conversationId, sourceMessageId) {
|
|
2277
|
+
const session = sessions.get(conversationId);
|
|
2278
|
+
if (!session || session.pendingInputs.length === 0)
|
|
2279
|
+
return;
|
|
2280
|
+
const before = session.pendingInputs.length;
|
|
2281
|
+
session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
|
|
2282
|
+
if (session.pendingInputs.length === before)
|
|
2283
|
+
return;
|
|
2284
|
+
session.writeTurn();
|
|
2285
|
+
}
|
|
2286
|
+
function evictOldestIdle() {
|
|
2287
|
+
let oldest = null;
|
|
2288
|
+
for (const s of sessions.values()) {
|
|
2289
|
+
if (s.state.state === 'running')
|
|
2290
|
+
continue; // Don't evict running sessions
|
|
2291
|
+
if (!oldest || s.lastActivity < oldest.lastActivity)
|
|
2292
|
+
oldest = s;
|
|
2293
|
+
}
|
|
2294
|
+
if (oldest) {
|
|
2295
|
+
console.error(`[canon-host] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
|
|
2296
|
+
closeSession(oldest.conversationId);
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
async function getOrCreateSession(conversationId) {
|
|
2300
|
+
knownConversationIds.add(conversationId);
|
|
2301
|
+
const existing = sessions.get(conversationId);
|
|
2302
|
+
if (existing && !existing.closed) {
|
|
2303
|
+
existing.lastActivity = Date.now();
|
|
2304
|
+
return existing;
|
|
2305
|
+
}
|
|
2306
|
+
const pending = pendingSessionCreations.get(conversationId);
|
|
2307
|
+
if (pending)
|
|
2308
|
+
return pending;
|
|
2309
|
+
// Evict if at capacity
|
|
2310
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
2311
|
+
evictOldestIdle();
|
|
2312
|
+
}
|
|
2313
|
+
const creation = (async () => {
|
|
2314
|
+
const config = await loadSessionConfig(conversationId, agentId);
|
|
1968
2315
|
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
1969
2316
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
1970
2317
|
const environment = prepareConversationEnvironment({
|
|
@@ -1981,7 +2328,7 @@ export async function main() {
|
|
|
1981
2328
|
executionMode: environment.mode,
|
|
1982
2329
|
});
|
|
1983
2330
|
const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
|
|
1984
|
-
const session = createSession(conversationId, environment, agentId,
|
|
2331
|
+
const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
|
|
1985
2332
|
// Called when session processing loop ends — save session ID for resume
|
|
1986
2333
|
const existing = sessions.get(convoId);
|
|
1987
2334
|
if (existing) {
|
|
@@ -2005,17 +2352,18 @@ export async function main() {
|
|
|
2005
2352
|
}
|
|
2006
2353
|
existing?.clearStreaming().catch(() => { });
|
|
2007
2354
|
typingSignals.clear(convoId).catch(() => { });
|
|
2355
|
+
typingSignals.dispose(convoId);
|
|
2008
2356
|
sessions.delete(convoId);
|
|
2009
|
-
|
|
2010
|
-
|
|
2357
|
+
runtimeState.clearSessionState(convoId).catch(() => { });
|
|
2358
|
+
runtimeState.clearTurnState(convoId).catch(() => { });
|
|
2011
2359
|
}, (descriptor) => {
|
|
2012
2360
|
runtimeDescriptor = descriptor;
|
|
2013
2361
|
void publishRuntimeHeartbeat();
|
|
2014
2362
|
}, {
|
|
2015
|
-
model:
|
|
2363
|
+
model: config?.model ?? runtimeModels[0]?.value,
|
|
2016
2364
|
permissionMode: config?.permissionMode ?? 'default',
|
|
2017
|
-
effort:
|
|
2018
|
-
|
|
2365
|
+
effort: config?.effort,
|
|
2366
|
+
ultracode: config?.ultracode,
|
|
2019
2367
|
availableModels: runtimeModels,
|
|
2020
2368
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
2021
2369
|
runtimePresentation,
|
|
@@ -2023,8 +2371,10 @@ export async function main() {
|
|
|
2023
2371
|
canRequestApproval: () => canRequestCanonApproval(conversationId),
|
|
2024
2372
|
runtimeId,
|
|
2025
2373
|
ownerId,
|
|
2374
|
+
publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
|
|
2026
2375
|
}, resumeId);
|
|
2027
2376
|
sessions.set(conversationId, session);
|
|
2377
|
+
await controlPoller.baseline([conversationId]);
|
|
2028
2378
|
return session;
|
|
2029
2379
|
}
|
|
2030
2380
|
catch (error) {
|
|
@@ -2032,6 +2382,13 @@ export async function main() {
|
|
|
2032
2382
|
throw error;
|
|
2033
2383
|
}
|
|
2034
2384
|
})();
|
|
2385
|
+
pendingSessionCreations.set(conversationId, creation);
|
|
2386
|
+
try {
|
|
2387
|
+
return await creation;
|
|
2388
|
+
}
|
|
2389
|
+
finally {
|
|
2390
|
+
pendingSessionCreations.delete(conversationId);
|
|
2391
|
+
}
|
|
2035
2392
|
}
|
|
2036
2393
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2037
2394
|
const m = input.message;
|
|
@@ -2088,9 +2445,7 @@ export async function main() {
|
|
|
2088
2445
|
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2089
2446
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
|
|
2090
2447
|
await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
|
|
2091
|
-
await
|
|
2092
|
-
conversationId: input.conversationId,
|
|
2093
|
-
text: `I couldn't start a coding session for this workspace: ${userMessage}`,
|
|
2448
|
+
await sendMessageWithRetry(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
|
|
2094
2449
|
messageId: `claude-start-failed-${m.id}`,
|
|
2095
2450
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2096
2451
|
metadata: {
|
|
@@ -2133,261 +2488,307 @@ export async function main() {
|
|
|
2133
2488
|
});
|
|
2134
2489
|
}
|
|
2135
2490
|
}
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
await enqueueRecoveredInboundMessage({
|
|
2161
|
-
conversationId: conversation.id,
|
|
2162
|
-
message: message,
|
|
2163
|
-
hydratedPage: recovery.newestPage,
|
|
2164
|
-
});
|
|
2165
|
-
}
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
catch (error) {
|
|
2169
|
-
console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
|
|
2170
|
-
}
|
|
2171
|
-
// ── Inbound Canon events (bridge notifications) ──
|
|
2172
|
-
// The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
|
|
2173
|
-
// HITL control-reply messages (approval_reply / plan_approval_reply) are
|
|
2174
|
-
// STRIPPED from onMessage bridge-side — unmatched ones arrive as
|
|
2175
|
-
// onControlReply and feed the plan-approval waiters below.
|
|
2176
|
-
const handleInboundMessage = (payload) => {
|
|
2177
|
-
const m = payload.message;
|
|
2178
|
-
if (m.senderId === agentId)
|
|
2179
|
-
return;
|
|
2180
|
-
const convoId = payload.conversationId;
|
|
2181
|
-
knownConversationIds.add(convoId);
|
|
2182
|
-
const sender = m.senderName || m.senderId;
|
|
2183
|
-
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2184
|
-
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2185
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2186
|
-
return;
|
|
2187
|
-
}
|
|
2188
|
-
void (async () => {
|
|
2189
|
-
let materialized = [];
|
|
2190
|
-
if (m.id) {
|
|
2191
|
-
try {
|
|
2192
|
-
materialized = await materializeMessageMedia({
|
|
2193
|
-
id: m.id,
|
|
2194
|
-
attachments: m.attachments ?? [],
|
|
2195
|
-
}, { agentId, conversationId: convoId });
|
|
2491
|
+
// ── Connect SSE stream for inbound Canon messages ──
|
|
2492
|
+
const stream = new CanonStream({
|
|
2493
|
+
apiKey,
|
|
2494
|
+
agentId,
|
|
2495
|
+
handler: {
|
|
2496
|
+
onMessage: (payload) => {
|
|
2497
|
+
const m = payload.message;
|
|
2498
|
+
if (m.senderId === agentId)
|
|
2499
|
+
return;
|
|
2500
|
+
const convoId = payload.conversationId;
|
|
2501
|
+
knownConversationIds.add(convoId);
|
|
2502
|
+
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2503
|
+
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2504
|
+
senderId: m.senderId,
|
|
2505
|
+
metadata: m.metadata,
|
|
2506
|
+
}) ?? false;
|
|
2507
|
+
if (m.metadata?.type === 'approval_reply') {
|
|
2508
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
if (consumed) {
|
|
2512
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2513
|
+
return;
|
|
2514
|
+
}
|
|
2196
2515
|
}
|
|
2197
|
-
|
|
2198
|
-
|
|
2516
|
+
// Check for metadata replies (plan approvals).
|
|
2517
|
+
if (m.metadata?.type === 'plan_approval_reply') {
|
|
2518
|
+
const existingSession = sessions.get(convoId);
|
|
2519
|
+
const pendingReply = existingSession?.pendingReply;
|
|
2520
|
+
const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2521
|
+
const replyRequestId = m.metadata.planId;
|
|
2522
|
+
if (!pendingReply) {
|
|
2523
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
if (!isOwnerReply) {
|
|
2527
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
|
|
2528
|
+
return;
|
|
2529
|
+
}
|
|
2530
|
+
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2531
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
if (existingSession?.pendingReply) {
|
|
2535
|
+
const { resolve } = existingSession.pendingReply;
|
|
2536
|
+
existingSession.pendingReply = null;
|
|
2537
|
+
existingSession.lastActivity = Date.now();
|
|
2538
|
+
resolve(m.metadata);
|
|
2539
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2199
2542
|
}
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
senderName: sender,
|
|
2206
|
-
isOwner,
|
|
2207
|
-
activeSelfContextId: payload.activeSelfContextId,
|
|
2208
|
-
selfContexts: payload.selfContexts,
|
|
2209
|
-
provenance: payload.provenance,
|
|
2210
|
-
});
|
|
2211
|
-
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2212
|
-
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
2213
|
-
const selfContexts = hydrated.selfContexts;
|
|
2214
|
-
const replyMedia = await materializePromptReplyContext({
|
|
2215
|
-
replyContext: hydrated.replyContext,
|
|
2216
|
-
agentId,
|
|
2217
|
-
conversationId: convoId,
|
|
2218
|
-
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
2219
|
-
});
|
|
2220
|
-
const replyContext = replyMedia.replyContext;
|
|
2221
|
-
const participantContext = hydrated.participantContext;
|
|
2222
|
-
const autoReply = payload.turnDispatch?.kind === 'run_turn'
|
|
2223
|
-
? {
|
|
2224
|
-
allow: true,
|
|
2225
|
-
reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
|
|
2543
|
+
const sender = m.senderName || m.senderId;
|
|
2544
|
+
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2545
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2546
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2547
|
+
return;
|
|
2226
2548
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
}
|
|
2337
|
-
}
|
|
2338
|
-
};
|
|
2339
|
-
notifications.attach({
|
|
2340
|
-
onMessage: (params) => {
|
|
2341
|
-
handleInboundMessage(params);
|
|
2342
|
-
},
|
|
2343
|
-
onMessageDeleted: (params) => {
|
|
2344
|
-
const payload = params;
|
|
2345
|
-
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
2346
|
-
},
|
|
2347
|
-
onConversationUpdated: (params) => {
|
|
2348
|
-
handleConversationUpdated(params);
|
|
2349
|
-
},
|
|
2350
|
-
onControlReply: (params) => {
|
|
2351
|
-
const payload = params;
|
|
2352
|
-
handleControlReply(payload.conversationId, payload.message);
|
|
2353
|
-
},
|
|
2354
|
-
ready: () => {
|
|
2355
|
-
streamConnected = true;
|
|
2356
|
-
void publishRuntimeHeartbeat();
|
|
2357
|
-
console.error('[canon-host] Bridge ready');
|
|
2358
|
-
},
|
|
2359
|
-
connectionState: (params) => {
|
|
2360
|
-
const { upstream } = params;
|
|
2361
|
-
if (upstream === 'connected') {
|
|
2549
|
+
void (async () => {
|
|
2550
|
+
let materialized = [];
|
|
2551
|
+
if (m.id) {
|
|
2552
|
+
try {
|
|
2553
|
+
materialized = await materializeMessageMedia({
|
|
2554
|
+
id: m.id,
|
|
2555
|
+
attachments: m.attachments ?? [],
|
|
2556
|
+
}, { agentId, conversationId: convoId });
|
|
2557
|
+
}
|
|
2558
|
+
catch (error) {
|
|
2559
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
const content = renderInboundContent(m, materialized);
|
|
2563
|
+
const hydrated = await loadHydratedInboundContext({
|
|
2564
|
+
conversationId: convoId,
|
|
2565
|
+
message: m,
|
|
2566
|
+
senderName: sender,
|
|
2567
|
+
isOwner,
|
|
2568
|
+
activeSelfContextId: payload.activeSelfContextId,
|
|
2569
|
+
selfContexts: payload.selfContexts,
|
|
2570
|
+
provenance: payload.provenance,
|
|
2571
|
+
});
|
|
2572
|
+
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2573
|
+
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
2574
|
+
const selfContexts = hydrated.selfContexts;
|
|
2575
|
+
const replyMedia = await materializePromptReplyContext({
|
|
2576
|
+
replyContext: hydrated.replyContext,
|
|
2577
|
+
agentId,
|
|
2578
|
+
conversationId: convoId,
|
|
2579
|
+
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
2580
|
+
});
|
|
2581
|
+
const replyContext = replyMedia.replyContext;
|
|
2582
|
+
const participantContext = hydrated.participantContext;
|
|
2583
|
+
const autoReply = payload.turnDispatch?.kind === 'run_turn'
|
|
2584
|
+
? {
|
|
2585
|
+
allow: true,
|
|
2586
|
+
reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
|
|
2587
|
+
}
|
|
2588
|
+
: decideAutoReply(participantContext, behavior);
|
|
2589
|
+
if (!autoReply.allow) {
|
|
2590
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
markGroupContextModeUsed(convoId, participantContext.groupContextMode);
|
|
2594
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
2595
|
+
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
2596
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
2597
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
2598
|
+
let session;
|
|
2599
|
+
try {
|
|
2600
|
+
session = await getOrCreateSession(convoId);
|
|
2601
|
+
}
|
|
2602
|
+
catch (error) {
|
|
2603
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2604
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2605
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2606
|
+
await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
|
|
2607
|
+
await sendMessageWithRetry(client, convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
|
|
2608
|
+
messageId: `claude-start-failed-${m.id}`,
|
|
2609
|
+
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2610
|
+
metadata: {
|
|
2611
|
+
turnSemantics: 'turn_complete',
|
|
2612
|
+
replyBehavior: 'suppress_auto_reply',
|
|
2613
|
+
},
|
|
2614
|
+
}).catch(() => { });
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
session.activeSelfContextId = activeSelfContextId;
|
|
2618
|
+
const promptText = buildCanonPrompt({
|
|
2619
|
+
content,
|
|
2620
|
+
conversationId: convoId,
|
|
2621
|
+
participantContext,
|
|
2622
|
+
behavior,
|
|
2623
|
+
selfContexts,
|
|
2624
|
+
activeSelfContextId,
|
|
2625
|
+
provenance: hydrated.provenance,
|
|
2626
|
+
replyContext,
|
|
2627
|
+
message: m,
|
|
2628
|
+
});
|
|
2629
|
+
const messageContent = await buildCanonUserContent({
|
|
2630
|
+
promptText,
|
|
2631
|
+
materialized: [...replyMedia.materialized, ...materialized],
|
|
2632
|
+
});
|
|
2633
|
+
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
2634
|
+
session.enqueueInbound({
|
|
2635
|
+
type: 'user',
|
|
2636
|
+
message: {
|
|
2637
|
+
role: 'user',
|
|
2638
|
+
content: messageContent,
|
|
2639
|
+
},
|
|
2640
|
+
parent_tool_use_id: null,
|
|
2641
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2642
|
+
if (m.id) {
|
|
2643
|
+
saveRuntimeSessionState(runtimeId, {
|
|
2644
|
+
conversationId: convoId,
|
|
2645
|
+
baseCwd: workingDir,
|
|
2646
|
+
lastInboundMessageId: m.id,
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
})();
|
|
2650
|
+
},
|
|
2651
|
+
onMessageDeleted: (payload) => {
|
|
2652
|
+
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
2653
|
+
},
|
|
2654
|
+
onConversationUpdated: (payload) => {
|
|
2655
|
+
handleConversationUpdated(payload);
|
|
2656
|
+
},
|
|
2657
|
+
onConnected: () => {
|
|
2362
2658
|
streamConnected = true;
|
|
2363
2659
|
void publishRuntimeHeartbeat();
|
|
2364
|
-
console.error('[canon-host]
|
|
2365
|
-
}
|
|
2366
|
-
|
|
2660
|
+
console.error('[canon-host] SSE connected');
|
|
2661
|
+
},
|
|
2662
|
+
onDisconnected: () => {
|
|
2367
2663
|
streamConnected = false;
|
|
2368
|
-
|
|
2369
|
-
console.error(
|
|
2370
|
-
}
|
|
2664
|
+
runtimeState.clearAgentRuntime().catch(() => { });
|
|
2665
|
+
console.error('[canon-host] SSE disconnected');
|
|
2666
|
+
},
|
|
2667
|
+
onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
|
|
2371
2668
|
},
|
|
2372
2669
|
});
|
|
2670
|
+
stream.start().catch((err) => {
|
|
2671
|
+
console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
|
|
2672
|
+
});
|
|
2373
2673
|
// ── Control signal watcher ──
|
|
2374
|
-
// Session control signals (model, mode, effort).
|
|
2375
|
-
//
|
|
2674
|
+
// Session control signals (model, mode, effort). A missing/closed session
|
|
2675
|
+
// leaves the node in place; lastSeen still advances, so a stale control is
|
|
2676
|
+
// never replayed once a session exists.
|
|
2376
2677
|
async function applyRuntimeSessionControl(event) {
|
|
2377
2678
|
const convoId = event.conversationId;
|
|
2378
2679
|
const session = sessions.get(convoId);
|
|
2379
|
-
if (!session || session.closed)
|
|
2380
|
-
|
|
2381
|
-
void publishSessionSnapshotsForConversations([convoId]);
|
|
2382
|
-
return;
|
|
2383
|
-
}
|
|
2680
|
+
if (!session || session.closed)
|
|
2681
|
+
return { consume: false };
|
|
2384
2682
|
const ctrl = event.control;
|
|
2385
2683
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
|
|
2386
|
-
applyClaudeSessionControl(
|
|
2684
|
+
await applyClaudeSessionControl({
|
|
2685
|
+
model: ctrl.model,
|
|
2686
|
+
permissionMode: ctrl.permissionMode,
|
|
2687
|
+
effort: ctrl.effort,
|
|
2688
|
+
ultracode: ctrl.runtimeControlValues?.ultracode,
|
|
2689
|
+
}, {
|
|
2690
|
+
state: session.state,
|
|
2691
|
+
parsePermissionMode: parseClaudePermissionMode,
|
|
2692
|
+
normalizeEffort: normalizeClaudeEffortLevel,
|
|
2693
|
+
parseUltracode: parseClaudeUltracodeMode,
|
|
2694
|
+
setModel: (model) => session.query.setModel(model),
|
|
2695
|
+
setPermissionMode: (mode) => session.query.setPermissionMode(mode),
|
|
2696
|
+
applyEffort: (level) => applyClaudeEffortLevel(session.query, level),
|
|
2697
|
+
applyUltracode: (enabled) => applyClaudeUltracode(session.query, enabled),
|
|
2698
|
+
setRuntimeControlError: (controlId, value, error) => session.setRuntimeControlError(controlId, value, error),
|
|
2699
|
+
clearRuntimeControlError: (controlId) => session.clearRuntimeControlError(controlId),
|
|
2700
|
+
// Publish the app-subscribed snapshot immediately so the control-pending
|
|
2701
|
+
// gate clears now instead of at the next 30s heartbeat (lag fix).
|
|
2702
|
+
publishSnapshot: () => { void publishSessionSnapshots([convoId]); },
|
|
2703
|
+
});
|
|
2704
|
+
session.writeState();
|
|
2387
2705
|
}
|
|
2388
|
-
//
|
|
2389
|
-
|
|
2390
|
-
|
|
2706
|
+
// Interrupt/stop-clear signal. Throwing leaves the node in place
|
|
2707
|
+
// (consumeOnError: false); lastSeen has already advanced, so the failed
|
|
2708
|
+
// signal is reported but never retried.
|
|
2709
|
+
async function handleRuntimeControlSignal(event) {
|
|
2710
|
+
const convoId = event.conversationId;
|
|
2711
|
+
const session = sessions.get(convoId);
|
|
2712
|
+
if (!session || session.closed)
|
|
2713
|
+
return { consume: false };
|
|
2714
|
+
if (event.type === 'new_session') {
|
|
2715
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2716
|
+
await resetRuntimeSession(session);
|
|
2717
|
+
return;
|
|
2718
|
+
}
|
|
2719
|
+
const isActive = session.state.state === 'running';
|
|
2720
|
+
if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2721
|
+
return; // Nothing to interrupt — consume the stale signal
|
|
2722
|
+
}
|
|
2723
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2724
|
+
if (event.type === 'stop_and_drop') {
|
|
2725
|
+
const droppedInputs = session.pendingInputs.splice(0);
|
|
2726
|
+
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2727
|
+
}
|
|
2728
|
+
if (session.activeInput) {
|
|
2729
|
+
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2730
|
+
}
|
|
2731
|
+
session.pendingFinalText = null;
|
|
2732
|
+
session.turnState = 'interrupted';
|
|
2733
|
+
session.clearStreaming().catch(() => { });
|
|
2734
|
+
typingSignals.clear(convoId).catch(() => { });
|
|
2735
|
+
runtimeState.writeTurnState(convoId, {
|
|
2736
|
+
turnId: session.currentTurnId,
|
|
2737
|
+
state: 'interrupted',
|
|
2738
|
+
queueDepth: session.pendingInputs.length,
|
|
2739
|
+
currentSpeakerId: agentId,
|
|
2740
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
2741
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
2742
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2743
|
+
completedAt: { '.sv': 'timestamp' },
|
|
2744
|
+
}).catch(() => { });
|
|
2745
|
+
if (isActive) {
|
|
2746
|
+
await session.query.interrupt();
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
controlPoller.start();
|
|
2750
|
+
// ── Heartbeat (per-session) ──
|
|
2751
|
+
const heartbeat = setInterval(() => {
|
|
2752
|
+
for (const session of sessions.values()) {
|
|
2753
|
+
session.writeState();
|
|
2754
|
+
if (session.state.state !== 'running' && session.state.state !== 'requires_action') {
|
|
2755
|
+
session.writeTurn();
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
void publishRuntimeHeartbeat();
|
|
2759
|
+
}, HEARTBEAT_MS);
|
|
2760
|
+
// ── Idle session cleanup ──
|
|
2761
|
+
const idleCheck = setInterval(() => {
|
|
2762
|
+
const now = Date.now();
|
|
2763
|
+
for (const convoId of [...sessions.keys()]) {
|
|
2764
|
+
const session = sessions.get(convoId);
|
|
2765
|
+
if (!session || session.state.state === 'running')
|
|
2766
|
+
continue;
|
|
2767
|
+
if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
|
|
2768
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Idle timeout`);
|
|
2769
|
+
closeSession(convoId);
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}, IDLE_CHECK_MS);
|
|
2773
|
+
// ── Graceful shutdown ──
|
|
2774
|
+
const shutdown = async () => {
|
|
2775
|
+
console.error('[canon-host] Shutting down...');
|
|
2776
|
+
controlPoller.stop();
|
|
2777
|
+
clearInterval(heartbeat);
|
|
2778
|
+
clearInterval(idleCheck);
|
|
2779
|
+
stream.stop();
|
|
2780
|
+
approvalManager?.dispose();
|
|
2781
|
+
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
2782
|
+
for (const convoId of [...sessions.keys()]) {
|
|
2783
|
+
closeSession(convoId);
|
|
2784
|
+
}
|
|
2785
|
+
markLocalRuntimeStopped(runtimeId);
|
|
2786
|
+
(lockHandle ?? getActiveProfileLock())?.release();
|
|
2787
|
+
process.exit(0);
|
|
2788
|
+
};
|
|
2789
|
+
process.on('SIGINT', shutdown);
|
|
2790
|
+
process.on('SIGTERM', shutdown);
|
|
2791
|
+
process.on('SIGHUP', shutdown);
|
|
2391
2792
|
console.error('[canon-host] Ready — sessions created on demand');
|
|
2392
2793
|
// Keep alive
|
|
2393
2794
|
await new Promise(() => { });
|
|
@@ -2396,7 +2797,7 @@ export async function main() {
|
|
|
2396
2797
|
runCli(import.meta.url, main, (err) => {
|
|
2397
2798
|
const message = err instanceof Error ? err.message : String(err);
|
|
2398
2799
|
console.error(`[canon-host] ${message}`);
|
|
2399
|
-
|
|
2800
|
+
getActiveProfileLock()?.release();
|
|
2400
2801
|
process.exit(1);
|
|
2401
2802
|
}, {
|
|
2402
2803
|
name: 'canon-claude',
|