@canonmsg/claude-code-plugin 0.22.0 → 0.23.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 +1051 -697
- 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 +6 -54
- package/dist/session-state.js +17 -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 +12 -12
- 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 {
|
|
40
|
-
import { claudeThinkingToConfig, parseClaudeSessionDefaults, resolveEffectiveSessionSettings, } from './session-defaults.js';
|
|
41
|
-
import { mapClaudeSubagentActivity } from './subagent-activity.js';
|
|
36
|
+
import { 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: [],
|
|
@@ -236,40 +203,15 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
236
203
|
RUNTIME_STOP_AND_DROP_ACTION,
|
|
237
204
|
];
|
|
238
205
|
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
206
|
return buildFirstPartyCodingRuntimeDescriptor({
|
|
262
207
|
clientType: 'claude-code',
|
|
263
208
|
models: input.models,
|
|
264
|
-
defaultModel: input.defaults?.model ?? input.models[0]?.value ?? null,
|
|
265
209
|
workspaces: input.workspaces,
|
|
266
210
|
workspaceRoots: input.workspaceRoots,
|
|
267
211
|
executionModes: input.executionModes,
|
|
268
212
|
permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
|
|
269
213
|
defaultPermissionMode: 'default',
|
|
270
214
|
effortOptions: [...CLAUDE_EFFORT_OPTIONS],
|
|
271
|
-
defaultEffort: input.defaults?.effort,
|
|
272
|
-
extraRuntimeControls,
|
|
273
215
|
streamingTextMode: 'delta',
|
|
274
216
|
turnModes: [
|
|
275
217
|
{
|
|
@@ -295,6 +237,26 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
295
237
|
commands,
|
|
296
238
|
});
|
|
297
239
|
}
|
|
240
|
+
const CLAUDE_EFFORT_VALUES = new Set(CLAUDE_EFFORT_OPTIONS.map((option) => option.value));
|
|
241
|
+
function parseClaudePermissionMode(value) {
|
|
242
|
+
if (value === 'acceptEdits'
|
|
243
|
+
|| value === 'auto'
|
|
244
|
+
|| value === 'bypassPermissions'
|
|
245
|
+
|| value === 'default'
|
|
246
|
+
|| value === 'dontAsk'
|
|
247
|
+
|| value === 'plan') {
|
|
248
|
+
return value;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
function normalizeClaudeEffortLevel(value) {
|
|
253
|
+
return typeof value === 'string' && CLAUDE_EFFORT_VALUES.has(value)
|
|
254
|
+
? value
|
|
255
|
+
: undefined;
|
|
256
|
+
}
|
|
257
|
+
async function applyClaudeEffortLevel(q, effort) {
|
|
258
|
+
await q.applyFlagSettings({ effortLevel: effort });
|
|
259
|
+
}
|
|
298
260
|
let cachedClaudeCliPath;
|
|
299
261
|
// The SDK bundles a Bun-compiled `claude` Mach-O binary next to
|
|
300
262
|
// claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
|
|
@@ -331,30 +293,21 @@ function resolveClaudeCliPath() {
|
|
|
331
293
|
console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
|
|
332
294
|
return undefined;
|
|
333
295
|
}
|
|
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
296
|
function toModelOptions(models) {
|
|
347
|
-
return models.map(
|
|
297
|
+
return models.map((model) => ({
|
|
298
|
+
value: model.value,
|
|
299
|
+
label: model.displayName,
|
|
300
|
+
}));
|
|
348
301
|
}
|
|
349
|
-
async function publishAgentRuntime(
|
|
350
|
-
await
|
|
351
|
-
clientType: 'claude-code',
|
|
352
|
-
runtime: runtime,
|
|
353
|
-
});
|
|
302
|
+
async function publishAgentRuntime(agentId, runtime) {
|
|
303
|
+
await publishHostAgentRuntime(agentId, 'claude-code', runtime);
|
|
354
304
|
}
|
|
355
|
-
async function loadSessionConfig(
|
|
356
|
-
const
|
|
357
|
-
|
|
305
|
+
async function loadSessionConfig(conversationId, agentId) {
|
|
306
|
+
const config = await loadHostSessionConfig({
|
|
307
|
+
conversationId,
|
|
308
|
+
agentId,
|
|
309
|
+
extraStringFields: ['permissionMode', 'effort'],
|
|
310
|
+
});
|
|
358
311
|
return {
|
|
359
312
|
...config,
|
|
360
313
|
permissionMode: config?.permissionMode
|
|
@@ -384,21 +337,6 @@ function resolveExecutionFallbackReason(environment) {
|
|
|
384
337
|
: environment.reason;
|
|
385
338
|
}
|
|
386
339
|
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
340
|
const emptyPrompt = {
|
|
403
341
|
async *[Symbol.asyncIterator]() { },
|
|
404
342
|
};
|
|
@@ -408,7 +346,6 @@ async function detectRuntimeModelsForSelection(cwd, model) {
|
|
|
408
346
|
options: {
|
|
409
347
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
410
348
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
411
|
-
model,
|
|
412
349
|
permissionMode: 'default',
|
|
413
350
|
cwd,
|
|
414
351
|
settings: { forceLoginMethod: 'claudeai' },
|
|
@@ -436,7 +373,6 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
|
|
|
436
373
|
options: {
|
|
437
374
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
438
375
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
439
|
-
model: 'default',
|
|
440
376
|
permissionMode: 'default',
|
|
441
377
|
cwd,
|
|
442
378
|
settings: { forceLoginMethod: 'claudeai' },
|
|
@@ -486,6 +422,9 @@ async function refreshClaudeRuntimeMetadata(input) {
|
|
|
486
422
|
});
|
|
487
423
|
return runtimeMetadataRefresh;
|
|
488
424
|
}
|
|
425
|
+
function sleep(ms) {
|
|
426
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
427
|
+
}
|
|
489
428
|
function mapClaudeQuestions(value) {
|
|
490
429
|
if (!Array.isArray(value))
|
|
491
430
|
return undefined;
|
|
@@ -617,22 +556,13 @@ function buildClaudeRuntimeInfo(input) {
|
|
|
617
556
|
tier: 'diagnostic',
|
|
618
557
|
}
|
|
619
558
|
: null,
|
|
620
|
-
|
|
559
|
+
runtimeModels[0]
|
|
621
560
|
? {
|
|
622
561
|
id: 'fallbackModel',
|
|
623
562
|
label: 'Fallback model',
|
|
624
|
-
value:
|
|
563
|
+
value: runtimeModels[0].label,
|
|
625
564
|
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',
|
|
565
|
+
source: 'host-default',
|
|
636
566
|
}
|
|
637
567
|
: null,
|
|
638
568
|
{
|
|
@@ -725,6 +655,9 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
725
655
|
const STREAMING_THROTTLE_MS = 250;
|
|
726
656
|
const HEARTBEAT_MS = 30_000;
|
|
727
657
|
const IDLE_CHECK_MS = 60_000;
|
|
658
|
+
const CONTROL_POLL_MS = 2000;
|
|
659
|
+
const IDLE_CONTROL_POLL_MS = 10_000;
|
|
660
|
+
const CONTROL_POLL_JITTER_MS = 1_000;
|
|
728
661
|
const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
729
662
|
const MAX_FINAL_DELIVERY_RETRIES = 1;
|
|
730
663
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
@@ -793,7 +726,7 @@ async function buildCanonUserContent(input) {
|
|
|
793
726
|
];
|
|
794
727
|
}
|
|
795
728
|
// ── Session factory ─────────────────────────────────────────────────
|
|
796
|
-
function createSession(conversationId, environment, agentId,
|
|
729
|
+
function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
797
730
|
const { cwd } = environment;
|
|
798
731
|
const cardResponseUserId = config.ownerId ?? undefined;
|
|
799
732
|
const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
|
|
@@ -853,15 +786,48 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
853
786
|
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
854
787
|
let session;
|
|
855
788
|
const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
789
|
+
function waitForReply(type, requestId) {
|
|
790
|
+
// Keep session alive while waiting for user input
|
|
791
|
+
session.lastActivity = Date.now();
|
|
792
|
+
return new Promise((resolve, reject) => {
|
|
793
|
+
const timer = setTimeout(() => {
|
|
794
|
+
session.pendingReply = null;
|
|
795
|
+
reject(new Error('Timed out waiting for user reply'));
|
|
796
|
+
}, REPLY_TIMEOUT_MS);
|
|
797
|
+
session.pendingReply = {
|
|
798
|
+
type,
|
|
799
|
+
requestId,
|
|
800
|
+
resolve: (metadata) => {
|
|
801
|
+
clearTimeout(timer);
|
|
802
|
+
resolve(metadata);
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
async function waitForRuntimeInputResponse(input) {
|
|
808
|
+
session.lastActivity = Date.now();
|
|
809
|
+
while (Date.now() < input.expiresAt) {
|
|
810
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
811
|
+
conversationId,
|
|
812
|
+
inputId: input.inputId,
|
|
813
|
+
}).catch(() => null);
|
|
814
|
+
if (response?.status === 'submitted') {
|
|
815
|
+
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
816
|
+
}
|
|
817
|
+
if (response?.status === 'cancelled' || response?.status === 'timeout') {
|
|
818
|
+
return { status: response.status };
|
|
819
|
+
}
|
|
820
|
+
await sleep(1_000);
|
|
821
|
+
}
|
|
822
|
+
const response = await client.consumeRuntimeInputResponse({
|
|
823
|
+
conversationId,
|
|
824
|
+
inputId: input.inputId,
|
|
825
|
+
}).catch(() => null);
|
|
826
|
+
if (response?.status === 'submitted') {
|
|
827
|
+
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
828
|
+
}
|
|
829
|
+
return { status: 'timeout' };
|
|
830
|
+
}
|
|
865
831
|
function buildArtifactMediaMetadata() {
|
|
866
832
|
return {
|
|
867
833
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
@@ -873,27 +839,16 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
873
839
|
};
|
|
874
840
|
}
|
|
875
841
|
const claudeCliPath = resolveClaudeCliPath();
|
|
876
|
-
const initialThinkingConfig = claudeThinkingToConfig(config.sessionSettings.thinking);
|
|
877
842
|
const queryOptions = {
|
|
878
843
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
879
844
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
880
|
-
model: config.model ?? 'default',
|
|
881
845
|
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
882
846
|
allowDangerouslySkipPermissions: true,
|
|
883
847
|
cwd,
|
|
884
848
|
includePartialMessages: true,
|
|
885
849
|
maxTurns: 200,
|
|
886
850
|
...(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
|
-
},
|
|
851
|
+
settings: { forceLoginMethod: 'claudeai' },
|
|
897
852
|
settingSources: ['project', 'local'],
|
|
898
853
|
...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
|
|
899
854
|
stderr: (data) => {
|
|
@@ -908,27 +863,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
908
863
|
const questionId = randomUUID();
|
|
909
864
|
const questions = mapClaudeQuestions(input.questions);
|
|
910
865
|
const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
|
|
911
|
-
await
|
|
866
|
+
await client.createRuntimeInputRequest({
|
|
912
867
|
conversationId,
|
|
913
868
|
inputId: questionId,
|
|
914
869
|
kind: 'clarify',
|
|
915
|
-
expiresAt
|
|
870
|
+
expiresAt,
|
|
916
871
|
title: 'Claude needs input',
|
|
917
872
|
prompt: questions?.length
|
|
918
873
|
? 'Claude needs your input to continue.'
|
|
919
874
|
: 'Claude needs input to continue.',
|
|
920
|
-
...(questions
|
|
921
|
-
? { questions: questions }
|
|
922
|
-
: { questions: [] }),
|
|
875
|
+
...(questions ? { questions } : {}),
|
|
923
876
|
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
924
877
|
...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
|
|
925
878
|
native: {
|
|
926
879
|
runtime: 'claude-code',
|
|
927
880
|
method: 'AskUserQuestion',
|
|
928
881
|
requestId: questionId,
|
|
929
|
-
|
|
882
|
+
turnId: session.currentTurnId ?? undefined,
|
|
930
883
|
},
|
|
931
|
-
|
|
884
|
+
turnId: session.currentTurnId ?? undefined,
|
|
932
885
|
});
|
|
933
886
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
934
887
|
const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
|
|
@@ -983,9 +936,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
983
936
|
...(cardResponseUser ?? {}),
|
|
984
937
|
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
985
938
|
});
|
|
986
|
-
await
|
|
987
|
-
conversationId,
|
|
988
|
-
text: planApproval.text,
|
|
939
|
+
await sendMessageWithRetry(client, conversationId, planApproval.text, {
|
|
989
940
|
messageId: `claude-plan-${planId}`,
|
|
990
941
|
...(session.activeSelfContextId
|
|
991
942
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -1101,13 +1052,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1101
1052
|
query: q,
|
|
1102
1053
|
sendInput,
|
|
1103
1054
|
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
|
|
1104
|
-
state:
|
|
1105
|
-
model:
|
|
1055
|
+
state: {
|
|
1056
|
+
model: undefined,
|
|
1106
1057
|
permissionMode: config.permissionMode,
|
|
1107
|
-
effort:
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
}),
|
|
1058
|
+
effort: undefined,
|
|
1059
|
+
state: 'idle',
|
|
1060
|
+
},
|
|
1111
1061
|
availableModels: config.availableModels,
|
|
1112
1062
|
streamingText: '',
|
|
1113
1063
|
streamingTimer: null,
|
|
@@ -1139,92 +1089,155 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1139
1089
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1140
1090
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1141
1091
|
};
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1092
|
+
const streamingOutput = createTurnOutputController({
|
|
1093
|
+
turnId: `claude-${conversationId}`,
|
|
1094
|
+
mode: 'delta',
|
|
1095
|
+
throttleMs: STREAMING_THROTTLE_MS,
|
|
1096
|
+
writeSnapshot: (snapshot) => {
|
|
1097
|
+
if (session.closed)
|
|
1098
|
+
return Promise.resolve();
|
|
1099
|
+
const turnId = session.currentTurnId ?? snapshot.turnId;
|
|
1100
|
+
session.streamingText = snapshot.text;
|
|
1101
|
+
const blocks = snapshot.blocks?.map((block) => ({ ...block, turnId }));
|
|
1102
|
+
return runtimeState.writeStreaming(conversationId, {
|
|
1103
|
+
text: snapshot.text,
|
|
1104
|
+
status: snapshot.status,
|
|
1105
|
+
messageId: turnId,
|
|
1106
|
+
turnId,
|
|
1107
|
+
blocks,
|
|
1108
|
+
});
|
|
1109
|
+
},
|
|
1110
|
+
clearSnapshot: () => {
|
|
1111
|
+
session.streamingText = '';
|
|
1112
|
+
return runtimeState.clearStreaming(conversationId);
|
|
1113
|
+
},
|
|
1163
1114
|
});
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1115
|
+
// ── Per-session streaming helpers ──
|
|
1116
|
+
function flushStreaming() {
|
|
1117
|
+
streamingOutput.flush().catch(() => { });
|
|
1118
|
+
}
|
|
1119
|
+
function onStreamDelta(text, segmentId) {
|
|
1120
|
+
if (segmentId) {
|
|
1121
|
+
streamingOutput.appendTextSegmentDelta(segmentId, text);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
streamingOutput.appendDelta(text);
|
|
1125
|
+
}
|
|
1168
1126
|
function isActiveTurnInterrupted() {
|
|
1169
1127
|
const turnKey = session.activeInput?.turnKey;
|
|
1170
1128
|
return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
|
|
1171
1129
|
}
|
|
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
|
-
|
|
1130
|
+
function clearStreaming() {
|
|
1131
|
+
return streamingOutput.clear().catch(() => { });
|
|
1132
|
+
}
|
|
1133
|
+
function getFinalTurnTrail() {
|
|
1134
|
+
const turnId = session.currentTurnId ?? `claude-${conversationId}`;
|
|
1135
|
+
return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
|
|
1136
|
+
}
|
|
1137
|
+
function clearIdleResetTimer() {
|
|
1138
|
+
if (!session.idleResetTimer)
|
|
1139
|
+
return;
|
|
1140
|
+
clearTimeout(session.idleResetTimer);
|
|
1141
|
+
session.idleResetTimer = null;
|
|
1142
|
+
}
|
|
1143
|
+
function clearFinalDeliveryTimer() {
|
|
1144
|
+
if (!session.finalDeliveryTimer)
|
|
1145
|
+
return;
|
|
1146
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
1147
|
+
session.finalDeliveryTimer = null;
|
|
1148
|
+
}
|
|
1149
|
+
function resetTurnToIdle() {
|
|
1150
|
+
if (session.closed)
|
|
1151
|
+
return;
|
|
1152
|
+
clearFinalDeliveryTimer();
|
|
1153
|
+
resetClaudeCompletedTurnState(session);
|
|
1154
|
+
stopVisibleWorkSignal();
|
|
1155
|
+
writeState();
|
|
1156
|
+
writeTurn();
|
|
1157
|
+
drainPendingInput();
|
|
1158
|
+
}
|
|
1159
|
+
function scheduleFinalHandoffReset() {
|
|
1160
|
+
clearIdleResetTimer();
|
|
1161
|
+
session.idleResetTimer = setTimeout(() => {
|
|
1162
|
+
session.idleResetTimer = null;
|
|
1163
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
1164
|
+
clearStreaming();
|
|
1165
|
+
resetTurnToIdle();
|
|
1166
|
+
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
1167
|
+
}
|
|
1168
|
+
function markPendingFinalDelivery(finalText, turn) {
|
|
1169
|
+
clearIdleResetTimer();
|
|
1170
|
+
const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
|
|
1171
|
+
? session.pendingFinalDelivery.retryCount
|
|
1172
|
+
: 0;
|
|
1173
|
+
session.pendingFinalText = finalText;
|
|
1174
|
+
session.pendingFinalDelivery = {
|
|
1175
|
+
turnKey: turn.turnKey,
|
|
1176
|
+
text: finalText,
|
|
1177
|
+
retryCount,
|
|
1178
|
+
messageId: buildClaudeFinalMessageId({
|
|
1179
|
+
agentId,
|
|
1180
|
+
conversationId,
|
|
1181
|
+
turnKey: turn.turnKey,
|
|
1182
|
+
}),
|
|
1183
|
+
};
|
|
1184
|
+
session.state.state = 'running';
|
|
1185
|
+
session.turnState = 'streaming';
|
|
1186
|
+
stopVisibleWorkSignal();
|
|
1187
|
+
streamingOutput.replaceSnapshot(finalText, 'streaming').catch(() => { });
|
|
1188
|
+
writeState();
|
|
1189
|
+
writeTurn();
|
|
1190
|
+
typingSignals.start(conversationId, 'typing').catch(() => { });
|
|
1191
|
+
}
|
|
1192
|
+
async function deliverFinalReply(finalText, turn = session.activeInput) {
|
|
1193
|
+
if (!shouldDeliverClaudeFinal({
|
|
1206
1194
|
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,
|
|
1195
|
+
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
1196
|
+
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
1197
|
+
})) {
|
|
1198
|
+
return true;
|
|
1199
|
+
}
|
|
1200
|
+
const deliverTurn = turn;
|
|
1201
|
+
if (!deliverTurn)
|
|
1202
|
+
return true;
|
|
1203
|
+
const finalMessageId = buildClaudeFinalMessageId({
|
|
1204
|
+
agentId,
|
|
1221
1205
|
conversationId,
|
|
1206
|
+
turnKey: deliverTurn.turnKey,
|
|
1222
1207
|
});
|
|
1223
|
-
|
|
1224
|
-
conversationId,
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1208
|
+
try {
|
|
1209
|
+
await sendMessageWithRetry(client, conversationId, finalText, {
|
|
1210
|
+
messageId: finalMessageId,
|
|
1211
|
+
...(session.activeSelfContextId
|
|
1212
|
+
? { selfContextId: session.activeSelfContextId }
|
|
1213
|
+
: {}),
|
|
1214
|
+
metadata: {
|
|
1215
|
+
turnId: session.currentTurnId,
|
|
1216
|
+
turnKey: deliverTurn.turnKey,
|
|
1217
|
+
sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
|
|
1218
|
+
turnSemantics: 'turn_complete',
|
|
1219
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1220
|
+
...(getFinalTurnTrail().length > 0
|
|
1221
|
+
? { turnTrail: getFinalTurnTrail() }
|
|
1222
|
+
: {}),
|
|
1223
|
+
},
|
|
1224
|
+
});
|
|
1225
|
+
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1226
|
+
session.state.state = 'idle';
|
|
1227
|
+
session.pendingFinalDelivery = null;
|
|
1228
|
+
clearFinalDeliveryTimer();
|
|
1229
|
+
writeState();
|
|
1230
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
|
|
1231
|
+
return true;
|
|
1232
|
+
}
|
|
1233
|
+
catch (err) {
|
|
1234
|
+
markPendingFinalDelivery(finalText, deliverTurn);
|
|
1235
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
function sendTurnArtifactFile(file) {
|
|
1240
|
+
return sendMediaFileMessage(client, conversationId, file.path, '', {
|
|
1228
1241
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1229
1242
|
metadata: buildArtifactMediaMetadata(),
|
|
1230
1243
|
});
|
|
@@ -1261,6 +1274,138 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1261
1274
|
await routeWorkspaceGeneratedArtifacts(input);
|
|
1262
1275
|
}
|
|
1263
1276
|
}
|
|
1277
|
+
function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
|
|
1278
|
+
if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
|
|
1279
|
+
return;
|
|
1280
|
+
if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
|
|
1281
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
|
|
1282
|
+
session.pendingFinalDelivery = null;
|
|
1283
|
+
resetTurnToIdle();
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
pending.retryCount += 1;
|
|
1287
|
+
session.pendingFinalDelivery = pending;
|
|
1288
|
+
clearFinalDeliveryTimer();
|
|
1289
|
+
session.finalDeliveryTimer = setTimeout(() => {
|
|
1290
|
+
session.finalDeliveryTimer = null;
|
|
1291
|
+
const retryTurn = session.activeInput?.turnKey === pending.turnKey
|
|
1292
|
+
? session.activeInput
|
|
1293
|
+
: null;
|
|
1294
|
+
void deliverFinalReply(pending.text, retryTurn).then((sent) => {
|
|
1295
|
+
if (sent) {
|
|
1296
|
+
scheduleFinalHandoffReset();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
scheduleFinalDeliveryRetry();
|
|
1300
|
+
});
|
|
1301
|
+
}, FINAL_DELIVERY_RETRY_MS);
|
|
1302
|
+
}
|
|
1303
|
+
function writeState() {
|
|
1304
|
+
const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
|
|
1305
|
+
? session.runtimeControlErrors
|
|
1306
|
+
: null;
|
|
1307
|
+
const controlState = {};
|
|
1308
|
+
const appliedAt = Date.now();
|
|
1309
|
+
if (session.state.model !== undefined) {
|
|
1310
|
+
controlState.model = { value: session.state.model, source: 'applied', appliedAt };
|
|
1311
|
+
}
|
|
1312
|
+
else if (config.model !== undefined) {
|
|
1313
|
+
controlState.model = { value: config.model, source: 'requested' };
|
|
1314
|
+
}
|
|
1315
|
+
if (session.state.permissionMode !== undefined) {
|
|
1316
|
+
controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
|
|
1317
|
+
}
|
|
1318
|
+
else if (config.permissionMode !== undefined) {
|
|
1319
|
+
controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
|
|
1320
|
+
}
|
|
1321
|
+
if (session.state.effort !== undefined) {
|
|
1322
|
+
controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
|
|
1323
|
+
}
|
|
1324
|
+
else if (config.effort !== undefined) {
|
|
1325
|
+
controlState.effort = { value: config.effort, source: 'requested' };
|
|
1326
|
+
}
|
|
1327
|
+
runtimeState.writeSessionState(conversationId, {
|
|
1328
|
+
model: session.state.model,
|
|
1329
|
+
permissionMode: session.state.permissionMode,
|
|
1330
|
+
effort: session.state.effort,
|
|
1331
|
+
controlState,
|
|
1332
|
+
runtimeControlErrors,
|
|
1333
|
+
contextUsage: session.state.contextUsage,
|
|
1334
|
+
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
1335
|
+
cwd,
|
|
1336
|
+
executionMode: session.environment.mode,
|
|
1337
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
1338
|
+
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
1339
|
+
...(resolveExecutionFallbackReason(session.environment)
|
|
1340
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
1341
|
+
: {}),
|
|
1342
|
+
hostMode: true,
|
|
1343
|
+
isActive: true,
|
|
1344
|
+
}).catch(() => { });
|
|
1345
|
+
}
|
|
1346
|
+
function setRuntimeControlError(controlId, value, error) {
|
|
1347
|
+
session.runtimeControlErrors = {
|
|
1348
|
+
...session.runtimeControlErrors,
|
|
1349
|
+
[controlId]: {
|
|
1350
|
+
value,
|
|
1351
|
+
message: formatClaudeControlError(error),
|
|
1352
|
+
updatedAt: Date.now(),
|
|
1353
|
+
},
|
|
1354
|
+
};
|
|
1355
|
+
writeState();
|
|
1356
|
+
}
|
|
1357
|
+
function clearRuntimeControlError(controlId) {
|
|
1358
|
+
if (!(controlId in session.runtimeControlErrors))
|
|
1359
|
+
return;
|
|
1360
|
+
const next = { ...session.runtimeControlErrors };
|
|
1361
|
+
delete next[controlId];
|
|
1362
|
+
session.runtimeControlErrors = next;
|
|
1363
|
+
writeState();
|
|
1364
|
+
}
|
|
1365
|
+
function writeTurn() {
|
|
1366
|
+
const isOpenTurn = session.turnState === 'thinking'
|
|
1367
|
+
|| session.turnState === 'streaming'
|
|
1368
|
+
|| session.turnState === 'tool'
|
|
1369
|
+
|| session.turnState === 'waiting_input';
|
|
1370
|
+
if (isOpenTurn) {
|
|
1371
|
+
session.currentTurnUpdatedAt = Date.now();
|
|
1372
|
+
}
|
|
1373
|
+
runtimeState.writeTurnState(conversationId, {
|
|
1374
|
+
turnId: session.currentTurnId,
|
|
1375
|
+
state: session.turnState,
|
|
1376
|
+
queueDepth: session.pendingInputs.length,
|
|
1377
|
+
currentSpeakerId: agentId,
|
|
1378
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
1379
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1380
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
1381
|
+
...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
|
|
1382
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
1383
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
1384
|
+
: {}),
|
|
1385
|
+
}).catch(() => { });
|
|
1386
|
+
}
|
|
1387
|
+
function refreshVisibleWorkSignal() {
|
|
1388
|
+
if (session.closed)
|
|
1389
|
+
return;
|
|
1390
|
+
if (session.turnState !== 'thinking' && session.turnState !== 'tool')
|
|
1391
|
+
return;
|
|
1392
|
+
typingSignals.start(conversationId, 'thinking').catch(() => { });
|
|
1393
|
+
}
|
|
1394
|
+
function startVisibleWorkSignal() {
|
|
1395
|
+
refreshVisibleWorkSignal();
|
|
1396
|
+
}
|
|
1397
|
+
function stopVisibleWorkSignal() {
|
|
1398
|
+
if (session.typingKeepaliveTimer) {
|
|
1399
|
+
clearInterval(session.typingKeepaliveTimer);
|
|
1400
|
+
session.typingKeepaliveTimer = null;
|
|
1401
|
+
}
|
|
1402
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
1403
|
+
}
|
|
1404
|
+
async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
|
|
1405
|
+
if (!markAccepted || !sourceMessageId)
|
|
1406
|
+
return;
|
|
1407
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
1408
|
+
}
|
|
1264
1409
|
async function prepareArtifactsForInput(input) {
|
|
1265
1410
|
if (input.kind !== 'canon')
|
|
1266
1411
|
return;
|
|
@@ -1276,8 +1421,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1276
1421
|
return;
|
|
1277
1422
|
}
|
|
1278
1423
|
}
|
|
1424
|
+
async function startCanonInput(input) {
|
|
1425
|
+
await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
|
|
1426
|
+
await prepareArtifactsForInput(input);
|
|
1427
|
+
session.state.state = 'running';
|
|
1428
|
+
sendInput(input);
|
|
1429
|
+
}
|
|
1430
|
+
function drainPendingInput() {
|
|
1431
|
+
if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
|
|
1432
|
+
return;
|
|
1433
|
+
const next = session.pendingInputs.shift();
|
|
1434
|
+
if (!next)
|
|
1435
|
+
return;
|
|
1436
|
+
session.lastAcceptedIntent = next.intent;
|
|
1437
|
+
writeTurn();
|
|
1438
|
+
void startCanonInput(next);
|
|
1439
|
+
}
|
|
1279
1440
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
1280
|
-
|
|
1441
|
+
session.lastActivity = Date.now();
|
|
1442
|
+
const input = createClaudeInputEnvelope({
|
|
1281
1443
|
kind: 'canon',
|
|
1282
1444
|
msg,
|
|
1283
1445
|
intent,
|
|
@@ -1285,11 +1447,35 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1285
1447
|
markAccepted,
|
|
1286
1448
|
isOwnerTurn,
|
|
1287
1449
|
artifactRoutingMode,
|
|
1288
|
-
})
|
|
1450
|
+
});
|
|
1451
|
+
if (session.pendingFinalDelivery) {
|
|
1452
|
+
session.pendingInputs.push(input);
|
|
1453
|
+
writeTurn();
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
if (session.state.state === 'running' && intent !== 'interrupt') {
|
|
1457
|
+
session.pendingInputs.push(input);
|
|
1458
|
+
writeTurn();
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
1462
|
+
if (session.activeInput) {
|
|
1463
|
+
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
1464
|
+
}
|
|
1465
|
+
session.pendingInputs.unshift(input);
|
|
1466
|
+
session.turnState = 'interrupted';
|
|
1467
|
+
stopVisibleWorkSignal();
|
|
1468
|
+
clearStreaming().catch(() => { });
|
|
1469
|
+
writeTurn();
|
|
1470
|
+
void session.query.interrupt().catch(() => { });
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
session.lastAcceptedIntent = intent;
|
|
1474
|
+
writeTurn();
|
|
1475
|
+
void startCanonInput(input);
|
|
1289
1476
|
};
|
|
1290
1477
|
writeTurn();
|
|
1291
1478
|
// ── Process SDK output messages ──
|
|
1292
|
-
let assistantStreamOrdinal = 0;
|
|
1293
1479
|
const processMessages = async () => {
|
|
1294
1480
|
try {
|
|
1295
1481
|
for await (const msg of q) {
|
|
@@ -1320,10 +1506,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1320
1506
|
stopVisibleWorkSignal();
|
|
1321
1507
|
break;
|
|
1322
1508
|
}
|
|
1323
|
-
if (event?.type === '
|
|
1324
|
-
assistantStreamOrdinal += 1;
|
|
1325
|
-
}
|
|
1326
|
-
else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
1509
|
+
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
1327
1510
|
session.toolInProgress = true;
|
|
1328
1511
|
session.turnState = 'tool';
|
|
1329
1512
|
streamingOutput.addBlock({
|
|
@@ -1352,35 +1535,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1352
1535
|
stopVisibleWorkSignal();
|
|
1353
1536
|
writeTurn();
|
|
1354
1537
|
const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
|
|
1355
|
-
|
|
1356
|
-
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
|
|
1538
|
+
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
|
|
1357
1539
|
}
|
|
1358
1540
|
break;
|
|
1359
1541
|
}
|
|
1360
1542
|
case 'system': {
|
|
1361
1543
|
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
1544
|
if (subtype === 'session_state_changed') {
|
|
1385
1545
|
const state = msg.state;
|
|
1386
1546
|
session.state.state = state;
|
|
@@ -1392,7 +1552,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1392
1552
|
}
|
|
1393
1553
|
session.state.contextUsage = undefined;
|
|
1394
1554
|
session.pendingFinalText = null;
|
|
1395
|
-
assistantStreamOrdinal = 0;
|
|
1396
1555
|
session.toolInProgress = false;
|
|
1397
1556
|
session.turnState = 'thinking';
|
|
1398
1557
|
startVisibleWorkSignal();
|
|
@@ -1489,6 +1648,24 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1489
1648
|
const init = async () => {
|
|
1490
1649
|
processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
|
|
1491
1650
|
await q.initializationResult();
|
|
1651
|
+
if (config.model) {
|
|
1652
|
+
try {
|
|
1653
|
+
await q.setModel(config.model);
|
|
1654
|
+
session.state.model = config.model;
|
|
1655
|
+
}
|
|
1656
|
+
catch (err) {
|
|
1657
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (config.effort) {
|
|
1661
|
+
try {
|
|
1662
|
+
await applyClaudeEffortLevel(q, config.effort);
|
|
1663
|
+
session.state.effort = config.effort;
|
|
1664
|
+
}
|
|
1665
|
+
catch (err) {
|
|
1666
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1492
1669
|
if (!resumeSessionId) {
|
|
1493
1670
|
// New session — send seed message to activate streaming input mode
|
|
1494
1671
|
sendInput(createClaudeInputEnvelope({
|
|
@@ -1503,32 +1680,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1503
1680
|
},
|
|
1504
1681
|
}));
|
|
1505
1682
|
}
|
|
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.
|
|
1683
|
+
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1509
1684
|
try {
|
|
1510
|
-
const
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
if (discoveredModels.length > 0) {
|
|
1515
|
-
runtimeModels = discoveredModels;
|
|
1516
|
-
}
|
|
1517
|
-
const modelList = resolveClaudeModelOptions({
|
|
1518
|
-
discovered: runtimeModels,
|
|
1685
|
+
const models = await q.supportedModels();
|
|
1686
|
+
const modelList = mergeClaudeModelOptions({
|
|
1687
|
+
previous: runtimeModels,
|
|
1688
|
+
next: toModelOptions(models),
|
|
1519
1689
|
selectedModel: session.state.model,
|
|
1520
1690
|
});
|
|
1521
1691
|
session.availableModels = modelList;
|
|
1692
|
+
runtimeModels = modelList;
|
|
1522
1693
|
onRuntimeDescriptorUpdate({
|
|
1523
1694
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1524
|
-
defaultModel:
|
|
1695
|
+
defaultModel: modelList[0]?.value,
|
|
1525
1696
|
defaultPermissionMode: 'default',
|
|
1526
1697
|
availableModels: modelList,
|
|
1527
1698
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1528
1699
|
availableExecutionModes: config.availableExecutionModes,
|
|
1529
1700
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1530
1701
|
models: modelList,
|
|
1531
|
-
defaults: config.sessionSettings,
|
|
1532
1702
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1533
1703
|
workspaceRoots: workspaceRootMetadata,
|
|
1534
1704
|
executionModes: config.availableExecutionModes,
|
|
@@ -1536,7 +1706,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1536
1706
|
}),
|
|
1537
1707
|
});
|
|
1538
1708
|
writeState();
|
|
1539
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${
|
|
1709
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
|
|
1540
1710
|
}
|
|
1541
1711
|
catch {
|
|
1542
1712
|
writeState();
|
|
@@ -1581,57 +1751,41 @@ export async function main() {
|
|
|
1581
1751
|
for (const warning of workspaceDiscovery.warnings) {
|
|
1582
1752
|
console.error(`[canon-host] ${warning}`);
|
|
1583
1753
|
}
|
|
1584
|
-
const { agentName: profileAgentName, profile,
|
|
1585
|
-
const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
|
|
1586
|
-
activeLockHandle = lockHandle ?? null;
|
|
1754
|
+
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
|
|
1587
1755
|
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),
|
|
1756
|
+
// ── Canon API client + RTDB auth ──
|
|
1757
|
+
const client = new CanonClient(apiKey, baseUrl);
|
|
1758
|
+
const rtdb = initRTDBAuth(client);
|
|
1759
|
+
const typingSignals = createTypingStatusPublisher({
|
|
1760
|
+
setTyping: (conversationId, typing, status) => status
|
|
1761
|
+
? client.setTyping(conversationId, typing, status)
|
|
1762
|
+
: client.setTyping(conversationId, typing),
|
|
1613
1763
|
});
|
|
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}`);
|
|
1619
|
-
});
|
|
1620
|
-
const typingSignals = createPortTypingSignals(port);
|
|
1621
1764
|
async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
|
|
1622
1765
|
if (!markAccepted || !sourceMessageId)
|
|
1623
1766
|
return;
|
|
1624
|
-
await
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1767
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
1768
|
+
}
|
|
1769
|
+
let agentId;
|
|
1770
|
+
let ownerId = null;
|
|
1771
|
+
let ownerName = null;
|
|
1772
|
+
try {
|
|
1773
|
+
const ctx = await client.getAgentMe();
|
|
1774
|
+
agentId = ctx.agentId;
|
|
1775
|
+
ownerId = ctx.ownerId;
|
|
1776
|
+
ownerName = ctx.ownerName;
|
|
1777
|
+
console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
|
|
1778
|
+
}
|
|
1779
|
+
catch {
|
|
1780
|
+
if (profileAgentId) {
|
|
1781
|
+
agentId = profileAgentId;
|
|
1782
|
+
}
|
|
1783
|
+
else {
|
|
1784
|
+
const auth = await client.getAuthToken();
|
|
1785
|
+
agentId = auth.agentId;
|
|
1786
|
+
}
|
|
1787
|
+
console.error(`[canon-host] Authenticated as ${agentId}`);
|
|
1629
1788
|
}
|
|
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
1789
|
const launchArgs = [...process.argv.slice(2)];
|
|
1636
1790
|
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
1637
1791
|
launchArgs.push('--cwd', workingDir);
|
|
@@ -1660,6 +1814,11 @@ export async function main() {
|
|
|
1660
1814
|
lastStartedAt: new Date().toISOString(),
|
|
1661
1815
|
lastHeartbeatAt: new Date().toISOString(),
|
|
1662
1816
|
});
|
|
1817
|
+
const runtimeState = createRuntimeStatePublisher({
|
|
1818
|
+
agentId,
|
|
1819
|
+
clientType: 'claude-code',
|
|
1820
|
+
hostMode: true,
|
|
1821
|
+
});
|
|
1663
1822
|
let streamConnected = false;
|
|
1664
1823
|
const hostAvailableExecutionModes = [
|
|
1665
1824
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1672,60 +1831,23 @@ export async function main() {
|
|
|
1672
1831
|
});
|
|
1673
1832
|
let runtimeDescriptor = {
|
|
1674
1833
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1675
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1676
1834
|
defaultPermissionMode: 'default',
|
|
1677
1835
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1678
1836
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1679
1837
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1680
1838
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1681
1839
|
models: runtimeModels,
|
|
1682
|
-
defaults: profileSessionDefaults,
|
|
1683
1840
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1684
1841
|
workspaceRoots: workspaceRootMetadata,
|
|
1685
1842
|
executionModes: hostAvailableExecutionModes,
|
|
1686
1843
|
presentation: runtimePresentation,
|
|
1687
1844
|
}),
|
|
1688
1845
|
};
|
|
1689
|
-
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
refreshFloorMs: HEARTBEAT_MS,
|
|
1695
|
-
}, { port });
|
|
1696
|
-
const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
|
|
1697
|
-
const publishSessionSnapshotsForConversations = async (conversationIds) => {
|
|
1698
|
-
const ids = Array.from(new Set(conversationIds));
|
|
1699
|
-
if (!streamConnected || ids.length === 0)
|
|
1700
|
-
return;
|
|
1701
|
-
await port.publishHostSessionSnapshots({
|
|
1702
|
-
conversationIds: ids,
|
|
1703
|
-
clientType: 'claude-code',
|
|
1704
|
-
runtime: runtimeDescriptor,
|
|
1705
|
-
workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
|
|
1706
|
-
defaultCwd: workingDir,
|
|
1707
|
-
extraSessionConfigFields: ['permissionMode', 'effort'],
|
|
1708
|
-
liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
|
|
1709
|
-
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
1710
|
-
return [
|
|
1711
|
-
session.conversationId,
|
|
1712
|
-
{
|
|
1713
|
-
...(session.state.model ? { model: session.state.model } : {}),
|
|
1714
|
-
...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
|
|
1715
|
-
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
1716
|
-
...(session.state.runtimeControlValues
|
|
1717
|
-
? { runtimeControlValues: session.state.runtimeControlValues }
|
|
1718
|
-
: {}),
|
|
1719
|
-
...(workspaceId ? { workspaceId } : {}),
|
|
1720
|
-
executionMode: session.environment.mode,
|
|
1721
|
-
executionBranch: session.environment.branch ?? null,
|
|
1722
|
-
},
|
|
1723
|
-
];
|
|
1724
|
-
})),
|
|
1725
|
-
}).catch((error) => {
|
|
1726
|
-
console.error('[canon-host] Failed to publish session snapshots:', error);
|
|
1727
|
-
});
|
|
1728
|
-
};
|
|
1846
|
+
const conversationCache = new Map();
|
|
1847
|
+
const knownConversationIds = new Set();
|
|
1848
|
+
const promptedGroupContextConversationIds = new Set();
|
|
1849
|
+
const pendingMembershipChanges = new Map();
|
|
1850
|
+
let lastKnownConversationRefreshAt = 0;
|
|
1729
1851
|
const publishRuntimeHeartbeat = async () => {
|
|
1730
1852
|
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
1731
1853
|
agentId,
|
|
@@ -1746,20 +1868,42 @@ export async function main() {
|
|
|
1746
1868
|
console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
|
|
1747
1869
|
return runtimeMetadata;
|
|
1748
1870
|
});
|
|
1749
|
-
await publishAgentRuntime(
|
|
1871
|
+
await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
|
|
1750
1872
|
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
1751
1873
|
});
|
|
1752
|
-
await
|
|
1874
|
+
await publishHostSessionSnapshots({
|
|
1875
|
+
conversationIds: Array.from(knownConversationIds),
|
|
1876
|
+
agentId,
|
|
1877
|
+
clientType: 'claude-code',
|
|
1878
|
+
runtime: runtimeDescriptor,
|
|
1879
|
+
workspaceOptions,
|
|
1880
|
+
defaultCwd: workingDir,
|
|
1881
|
+
extraSessionConfigFields: ['permissionMode', 'effort'],
|
|
1882
|
+
liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
|
|
1883
|
+
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
1884
|
+
return [
|
|
1885
|
+
session.conversationId,
|
|
1886
|
+
{
|
|
1887
|
+
...(session.state.model ? { model: session.state.model } : {}),
|
|
1888
|
+
...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
|
|
1889
|
+
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
1890
|
+
...(workspaceId ? { workspaceId } : {}),
|
|
1891
|
+
executionMode: session.environment.mode,
|
|
1892
|
+
executionBranch: session.environment.branch ?? null,
|
|
1893
|
+
},
|
|
1894
|
+
];
|
|
1895
|
+
})),
|
|
1896
|
+
}).catch((error) => {
|
|
1897
|
+
console.error('[canon-host] Failed to publish session snapshots:', error);
|
|
1898
|
+
});
|
|
1753
1899
|
// Rebuild so the descriptor picks up native slash commands from the
|
|
1754
1900
|
// metadata refresh above — the initial descriptor is built before the
|
|
1755
1901
|
// command inventory has loaded.
|
|
1756
1902
|
if (runtimeDescriptor.runtimeDescriptor) {
|
|
1757
1903
|
runtimeDescriptor = {
|
|
1758
1904
|
...runtimeDescriptor,
|
|
1759
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1760
1905
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1761
1906
|
models: runtimeModels,
|
|
1762
|
-
defaults: profileSessionDefaults,
|
|
1763
1907
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1764
1908
|
workspaceRoots: workspaceRootMetadata,
|
|
1765
1909
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1787,15 +1931,8 @@ export async function main() {
|
|
|
1787
1931
|
executionBranch: session?.environment.branch ?? null,
|
|
1788
1932
|
worktreePath: session?.environment.worktreePath ?? null,
|
|
1789
1933
|
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
1934
|
});
|
|
1935
|
+
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
1799
1936
|
})).catch((error) => {
|
|
1800
1937
|
console.error('[canon-host] Failed to publish runtime info:', error);
|
|
1801
1938
|
});
|
|
@@ -1805,7 +1942,7 @@ export async function main() {
|
|
|
1805
1942
|
runtimeModels = await detectRuntimeModels(workingDir);
|
|
1806
1943
|
runtimeDescriptor = {
|
|
1807
1944
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1808
|
-
defaultModel:
|
|
1945
|
+
defaultModel: runtimeModels[0]?.value,
|
|
1809
1946
|
defaultPermissionMode: 'default',
|
|
1810
1947
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1811
1948
|
availableModels: runtimeModels,
|
|
@@ -1813,7 +1950,6 @@ export async function main() {
|
|
|
1813
1950
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1814
1951
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1815
1952
|
models: runtimeModels,
|
|
1816
|
-
defaults: profileSessionDefaults,
|
|
1817
1953
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1818
1954
|
workspaceRoots: workspaceRootMetadata,
|
|
1819
1955
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1824,14 +1960,12 @@ export async function main() {
|
|
|
1824
1960
|
catch {
|
|
1825
1961
|
runtimeDescriptor = {
|
|
1826
1962
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1827
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1828
1963
|
defaultPermissionMode: 'default',
|
|
1829
1964
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1830
1965
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1831
1966
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1832
1967
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1833
1968
|
models: runtimeModels,
|
|
1834
|
-
defaults: profileSessionDefaults,
|
|
1835
1969
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1836
1970
|
workspaceRoots: workspaceRootMetadata,
|
|
1837
1971
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1840,12 +1974,14 @@ export async function main() {
|
|
|
1840
1974
|
};
|
|
1841
1975
|
}
|
|
1842
1976
|
try {
|
|
1843
|
-
const convos = await
|
|
1844
|
-
|
|
1977
|
+
const convos = await client.getConversations();
|
|
1978
|
+
lastKnownConversationRefreshAt = Date.now();
|
|
1845
1979
|
for (const conversation of convos) {
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1980
|
+
knownConversationIds.add(conversation.id);
|
|
1981
|
+
conversationCache.set(conversation.id, conversation);
|
|
1982
|
+
runtimeState.clearStreaming(conversation.id).catch(() => { });
|
|
1983
|
+
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
1984
|
+
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
1849
1985
|
typingSignals.clear(conversation.id).catch(() => { });
|
|
1850
1986
|
}
|
|
1851
1987
|
console.error(`[canon-host] ${convos.length} conversations`);
|
|
@@ -1853,68 +1989,64 @@ export async function main() {
|
|
|
1853
1989
|
catch {
|
|
1854
1990
|
console.error('[canon-host] Could not load conversations');
|
|
1855
1991
|
}
|
|
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();
|
|
1992
|
+
// ── Session manager ──
|
|
1993
|
+
const sessions = new Map();
|
|
1994
|
+
const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
|
|
1995
|
+
const pendingSessionCreations = new Map();
|
|
1996
|
+
// Shared /control channel poller (claude host profile): session + signal
|
|
1997
|
+
// keys, sequential conversations, active/idle cadence + jitter sampled at
|
|
1998
|
+
// cycle start, eager baselines at session creation, and nodes left in place
|
|
1999
|
+
// when a handler throws (consumeOnError defaults to false for both keys).
|
|
2000
|
+
const controlPoller = new ControlChannelPoller({
|
|
2001
|
+
rtdb,
|
|
2002
|
+
agentId,
|
|
2003
|
+
conversationIds: () => sessions.keys(),
|
|
2004
|
+
cadence: {
|
|
2005
|
+
kind: 'adaptive',
|
|
2006
|
+
activeMs: CONTROL_POLL_MS,
|
|
2007
|
+
idleMs: IDLE_CONTROL_POLL_MS,
|
|
2008
|
+
jitterMs: CONTROL_POLL_JITTER_MS,
|
|
2009
|
+
hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
|
|
2010
|
+
&& (session.state.state === 'running'
|
|
2011
|
+
|| session.state.state === 'requires_action'
|
|
2012
|
+
|| session.pendingInputs.length > 0
|
|
2013
|
+
|| Boolean(session.pendingFinalDelivery))),
|
|
2014
|
+
activitySample: 'cycle-start',
|
|
1885
2015
|
},
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
2016
|
+
pollOnStart: true,
|
|
2017
|
+
conversationConcurrency: 'sequential',
|
|
2018
|
+
handlers: {
|
|
2019
|
+
session: { handle: applyRuntimeSessionControl },
|
|
2020
|
+
signal: { handle: handleRuntimeControlSignal },
|
|
2021
|
+
},
|
|
2022
|
+
onError: ({ scope, key, conversationId, error }) => {
|
|
2023
|
+
if (scope === 'read')
|
|
2024
|
+
return; // Transient RTDB errors — retry next cycle
|
|
2025
|
+
const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
|
|
2026
|
+
console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
|
|
1889
2027
|
},
|
|
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
2028
|
});
|
|
1913
2029
|
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
1914
2030
|
const savedSessionIds = new Map();
|
|
2031
|
+
const { getConversationMeta } = createConversationMetadataLoader({
|
|
2032
|
+
client,
|
|
2033
|
+
conversationCache,
|
|
2034
|
+
});
|
|
1915
2035
|
function resolveWorkspaceIdForBaseCwd(baseCwd) {
|
|
1916
2036
|
return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
|
|
1917
2037
|
}
|
|
2038
|
+
async function refreshKnownConversationIds(force = false) {
|
|
2039
|
+
if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const conversations = await client.getConversations();
|
|
2043
|
+
knownConversationIds.clear();
|
|
2044
|
+
for (const conversation of conversations) {
|
|
2045
|
+
knownConversationIds.add(conversation.id);
|
|
2046
|
+
conversationCache.set(conversation.id, conversation);
|
|
2047
|
+
}
|
|
2048
|
+
lastKnownConversationRefreshAt = Date.now();
|
|
2049
|
+
}
|
|
1918
2050
|
async function canRequestCanonApproval(conversationId) {
|
|
1919
2051
|
if (!ownerId)
|
|
1920
2052
|
return false;
|
|
@@ -1922,9 +2054,80 @@ export async function main() {
|
|
|
1922
2054
|
.catch(() => conversationCache.get(conversationId) ?? null);
|
|
1923
2055
|
return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
|
|
1924
2056
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
2057
|
+
function handleConversationUpdated(payload) {
|
|
2058
|
+
const rawMemberIds = payload.changes.memberIds;
|
|
2059
|
+
if (!Array.isArray(rawMemberIds))
|
|
2060
|
+
return;
|
|
2061
|
+
const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
|
|
2062
|
+
const cached = conversationCache.get(payload.conversationId);
|
|
2063
|
+
const membershipChange = payload.membershipChange
|
|
2064
|
+
?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
|
|
2065
|
+
if (cached) {
|
|
2066
|
+
conversationCache.set(payload.conversationId, {
|
|
2067
|
+
...cached,
|
|
2068
|
+
memberIds,
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
2071
|
+
if (membershipChange) {
|
|
2072
|
+
pendingMembershipChanges.set(payload.conversationId, membershipChange);
|
|
2073
|
+
}
|
|
2074
|
+
if (!memberIds.includes(agentId)) {
|
|
2075
|
+
knownConversationIds.delete(payload.conversationId);
|
|
2076
|
+
conversationCache.delete(payload.conversationId);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
function getGroupContextMode(conversationId, conversation) {
|
|
2080
|
+
if (conversation?.type !== 'group')
|
|
2081
|
+
return undefined;
|
|
2082
|
+
if (pendingMembershipChanges.has(conversationId))
|
|
2083
|
+
return 'membership_change';
|
|
2084
|
+
if (!promptedGroupContextConversationIds.has(conversationId))
|
|
2085
|
+
return 'initial';
|
|
2086
|
+
return undefined;
|
|
2087
|
+
}
|
|
2088
|
+
function markGroupContextModeUsed(conversationId, mode) {
|
|
2089
|
+
if (!mode)
|
|
2090
|
+
return;
|
|
2091
|
+
promptedGroupContextConversationIds.add(conversationId);
|
|
2092
|
+
if (mode === 'membership_change') {
|
|
2093
|
+
pendingMembershipChanges.delete(conversationId);
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
async function loadHydratedInboundContext(input) {
|
|
2097
|
+
const [conversation, page] = await Promise.all([
|
|
2098
|
+
getConversationMeta(input.conversationId),
|
|
2099
|
+
input.hydratedPage
|
|
2100
|
+
? Promise.resolve(input.hydratedPage)
|
|
2101
|
+
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
2102
|
+
]);
|
|
2103
|
+
return buildHydratedInboundContext({
|
|
2104
|
+
agentId,
|
|
2105
|
+
conversationId: input.conversationId,
|
|
2106
|
+
conversation,
|
|
2107
|
+
page,
|
|
2108
|
+
activeSelfContextId: input.activeSelfContextId,
|
|
2109
|
+
selfContexts: input.selfContexts,
|
|
2110
|
+
provenance: input.provenance,
|
|
2111
|
+
message: input.message,
|
|
2112
|
+
senderName: input.senderName,
|
|
2113
|
+
isOwner: input.isOwner,
|
|
2114
|
+
ownerId,
|
|
2115
|
+
ownerName,
|
|
2116
|
+
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
2117
|
+
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
function closeSession(conversationId, options = {}) {
|
|
2121
|
+
const session = sessions.get(conversationId);
|
|
2122
|
+
if (!session)
|
|
2123
|
+
return;
|
|
2124
|
+
const saveResume = options.saveResume !== false;
|
|
2125
|
+
session.closed = true;
|
|
2126
|
+
if (session.typingKeepaliveTimer) {
|
|
2127
|
+
clearInterval(session.typingKeepaliveTimer);
|
|
2128
|
+
session.typingKeepaliveTimer = null;
|
|
2129
|
+
}
|
|
2130
|
+
releaseConversationEnvironment(session.environment);
|
|
1928
2131
|
const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
|
|
1929
2132
|
if (saveResume && session.sdkSessionId) {
|
|
1930
2133
|
savedSessionIds.set(resumeKey, session.sdkSessionId);
|
|
@@ -1956,15 +2159,89 @@ export async function main() {
|
|
|
1956
2159
|
});
|
|
1957
2160
|
}
|
|
1958
2161
|
}
|
|
2162
|
+
session.clearStreaming().catch(() => { });
|
|
2163
|
+
if (session.idleResetTimer) {
|
|
2164
|
+
clearTimeout(session.idleResetTimer);
|
|
2165
|
+
session.idleResetTimer = null;
|
|
2166
|
+
}
|
|
2167
|
+
if (session.finalDeliveryTimer) {
|
|
2168
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
2169
|
+
session.finalDeliveryTimer = null;
|
|
2170
|
+
}
|
|
2171
|
+
runtimeState.clearSessionState(conversationId).catch(() => { });
|
|
2172
|
+
runtimeState.clearTurnState(conversationId).catch(() => { });
|
|
2173
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
2174
|
+
typingSignals.dispose(conversationId);
|
|
2175
|
+
try {
|
|
2176
|
+
session.query.close();
|
|
2177
|
+
}
|
|
2178
|
+
catch { }
|
|
2179
|
+
sessions.delete(conversationId);
|
|
2180
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
|
|
1959
2181
|
}
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
2182
|
+
async function resetRuntimeSession(session) {
|
|
2183
|
+
const conversationId = session.conversationId;
|
|
2184
|
+
const droppedInputs = session.pendingInputs.splice(0);
|
|
2185
|
+
await markSessionPendingInputsRejected(conversationId, droppedInputs);
|
|
2186
|
+
if (session.activeInput) {
|
|
2187
|
+
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2188
|
+
}
|
|
2189
|
+
session.pendingFinalText = null;
|
|
2190
|
+
session.pendingFinalDelivery = null;
|
|
2191
|
+
session.turnState = 'interrupted';
|
|
2192
|
+
session.clearStreaming().catch(() => { });
|
|
2193
|
+
typingSignals.clear(conversationId).catch(() => { });
|
|
2194
|
+
if (session.state.state === 'running') {
|
|
2195
|
+
await session.query.interrupt().catch(() => { });
|
|
2196
|
+
}
|
|
2197
|
+
closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
|
|
2198
|
+
}
|
|
2199
|
+
async function markSessionPendingInputsRejected(conversationId, inputs) {
|
|
2200
|
+
await Promise.all(inputs.map((input) => {
|
|
2201
|
+
if (!input.markAccepted || !input.sourceMessageId)
|
|
2202
|
+
return Promise.resolve();
|
|
2203
|
+
return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
|
|
2204
|
+
}));
|
|
2205
|
+
}
|
|
2206
|
+
function removeQueuedInput(conversationId, sourceMessageId) {
|
|
2207
|
+
const session = sessions.get(conversationId);
|
|
2208
|
+
if (!session || session.pendingInputs.length === 0)
|
|
2209
|
+
return;
|
|
2210
|
+
const before = session.pendingInputs.length;
|
|
2211
|
+
session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
|
|
2212
|
+
if (session.pendingInputs.length === before)
|
|
2213
|
+
return;
|
|
2214
|
+
session.writeTurn();
|
|
2215
|
+
}
|
|
2216
|
+
function evictOldestIdle() {
|
|
2217
|
+
let oldest = null;
|
|
2218
|
+
for (const s of sessions.values()) {
|
|
2219
|
+
if (s.state.state === 'running')
|
|
2220
|
+
continue; // Don't evict running sessions
|
|
2221
|
+
if (!oldest || s.lastActivity < oldest.lastActivity)
|
|
2222
|
+
oldest = s;
|
|
2223
|
+
}
|
|
2224
|
+
if (oldest) {
|
|
2225
|
+
console.error(`[canon-host] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
|
|
2226
|
+
closeSession(oldest.conversationId);
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
async function getOrCreateSession(conversationId) {
|
|
2230
|
+
knownConversationIds.add(conversationId);
|
|
2231
|
+
const existing = sessions.get(conversationId);
|
|
2232
|
+
if (existing && !existing.closed) {
|
|
2233
|
+
existing.lastActivity = Date.now();
|
|
2234
|
+
return existing;
|
|
2235
|
+
}
|
|
2236
|
+
const pending = pendingSessionCreations.get(conversationId);
|
|
2237
|
+
if (pending)
|
|
2238
|
+
return pending;
|
|
2239
|
+
// Evict if at capacity
|
|
2240
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
2241
|
+
evictOldestIdle();
|
|
2242
|
+
}
|
|
2243
|
+
const creation = (async () => {
|
|
2244
|
+
const config = await loadSessionConfig(conversationId, agentId);
|
|
1968
2245
|
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
1969
2246
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
1970
2247
|
const environment = prepareConversationEnvironment({
|
|
@@ -1981,7 +2258,7 @@ export async function main() {
|
|
|
1981
2258
|
executionMode: environment.mode,
|
|
1982
2259
|
});
|
|
1983
2260
|
const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
|
|
1984
|
-
const session = createSession(conversationId, environment, agentId,
|
|
2261
|
+
const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
|
|
1985
2262
|
// Called when session processing loop ends — save session ID for resume
|
|
1986
2263
|
const existing = sessions.get(convoId);
|
|
1987
2264
|
if (existing) {
|
|
@@ -2005,17 +2282,17 @@ export async function main() {
|
|
|
2005
2282
|
}
|
|
2006
2283
|
existing?.clearStreaming().catch(() => { });
|
|
2007
2284
|
typingSignals.clear(convoId).catch(() => { });
|
|
2285
|
+
typingSignals.dispose(convoId);
|
|
2008
2286
|
sessions.delete(convoId);
|
|
2009
|
-
|
|
2010
|
-
|
|
2287
|
+
runtimeState.clearSessionState(convoId).catch(() => { });
|
|
2288
|
+
runtimeState.clearTurnState(convoId).catch(() => { });
|
|
2011
2289
|
}, (descriptor) => {
|
|
2012
2290
|
runtimeDescriptor = descriptor;
|
|
2013
2291
|
void publishRuntimeHeartbeat();
|
|
2014
2292
|
}, {
|
|
2015
|
-
model:
|
|
2293
|
+
model: config?.model ?? runtimeModels[0]?.value,
|
|
2016
2294
|
permissionMode: config?.permissionMode ?? 'default',
|
|
2017
|
-
effort:
|
|
2018
|
-
sessionSettings,
|
|
2295
|
+
effort: config?.effort,
|
|
2019
2296
|
availableModels: runtimeModels,
|
|
2020
2297
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
2021
2298
|
runtimePresentation,
|
|
@@ -2025,6 +2302,7 @@ export async function main() {
|
|
|
2025
2302
|
ownerId,
|
|
2026
2303
|
}, resumeId);
|
|
2027
2304
|
sessions.set(conversationId, session);
|
|
2305
|
+
await controlPoller.baseline([conversationId]);
|
|
2028
2306
|
return session;
|
|
2029
2307
|
}
|
|
2030
2308
|
catch (error) {
|
|
@@ -2032,6 +2310,13 @@ export async function main() {
|
|
|
2032
2310
|
throw error;
|
|
2033
2311
|
}
|
|
2034
2312
|
})();
|
|
2313
|
+
pendingSessionCreations.set(conversationId, creation);
|
|
2314
|
+
try {
|
|
2315
|
+
return await creation;
|
|
2316
|
+
}
|
|
2317
|
+
finally {
|
|
2318
|
+
pendingSessionCreations.delete(conversationId);
|
|
2319
|
+
}
|
|
2035
2320
|
}
|
|
2036
2321
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2037
2322
|
const m = input.message;
|
|
@@ -2088,9 +2373,7 @@ export async function main() {
|
|
|
2088
2373
|
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2089
2374
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
|
|
2090
2375
|
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}`,
|
|
2376
|
+
await sendMessageWithRetry(client, input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
|
|
2094
2377
|
messageId: `claude-start-failed-${m.id}`,
|
|
2095
2378
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2096
2379
|
metadata: {
|
|
@@ -2133,261 +2416,332 @@ export async function main() {
|
|
|
2133
2416
|
});
|
|
2134
2417
|
}
|
|
2135
2418
|
}
|
|
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 });
|
|
2419
|
+
// ── Connect SSE stream for inbound Canon messages ──
|
|
2420
|
+
const stream = new CanonStream({
|
|
2421
|
+
apiKey,
|
|
2422
|
+
agentId,
|
|
2423
|
+
handler: {
|
|
2424
|
+
onMessage: (payload) => {
|
|
2425
|
+
const m = payload.message;
|
|
2426
|
+
if (m.senderId === agentId)
|
|
2427
|
+
return;
|
|
2428
|
+
const convoId = payload.conversationId;
|
|
2429
|
+
knownConversationIds.add(convoId);
|
|
2430
|
+
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2431
|
+
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2432
|
+
senderId: m.senderId,
|
|
2433
|
+
metadata: m.metadata,
|
|
2434
|
+
}) ?? false;
|
|
2435
|
+
if (m.metadata?.type === 'approval_reply') {
|
|
2436
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2437
|
+
return;
|
|
2438
|
+
}
|
|
2439
|
+
if (consumed) {
|
|
2440
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2441
|
+
return;
|
|
2442
|
+
}
|
|
2196
2443
|
}
|
|
2197
|
-
|
|
2198
|
-
|
|
2444
|
+
// Check for metadata replies (plan approvals).
|
|
2445
|
+
if (m.metadata?.type === 'plan_approval_reply') {
|
|
2446
|
+
const existingSession = sessions.get(convoId);
|
|
2447
|
+
const pendingReply = existingSession?.pendingReply;
|
|
2448
|
+
const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2449
|
+
const replyRequestId = m.metadata.planId;
|
|
2450
|
+
if (!pendingReply) {
|
|
2451
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
|
|
2452
|
+
return;
|
|
2453
|
+
}
|
|
2454
|
+
if (!isOwnerReply) {
|
|
2455
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2459
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2460
|
+
return;
|
|
2461
|
+
}
|
|
2462
|
+
if (existingSession?.pendingReply) {
|
|
2463
|
+
const { resolve } = existingSession.pendingReply;
|
|
2464
|
+
existingSession.pendingReply = null;
|
|
2465
|
+
existingSession.lastActivity = Date.now();
|
|
2466
|
+
resolve(m.metadata);
|
|
2467
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2468
|
+
return;
|
|
2469
|
+
}
|
|
2199
2470
|
}
|
|
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',
|
|
2471
|
+
const sender = m.senderName || m.senderId;
|
|
2472
|
+
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2473
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2474
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2475
|
+
return;
|
|
2226
2476
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2477
|
+
void (async () => {
|
|
2478
|
+
let materialized = [];
|
|
2479
|
+
if (m.id) {
|
|
2480
|
+
try {
|
|
2481
|
+
materialized = await materializeMessageMedia({
|
|
2482
|
+
id: m.id,
|
|
2483
|
+
attachments: m.attachments ?? [],
|
|
2484
|
+
}, { agentId, conversationId: convoId });
|
|
2485
|
+
}
|
|
2486
|
+
catch (error) {
|
|
2487
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
const content = renderInboundContent(m, materialized);
|
|
2491
|
+
const hydrated = await loadHydratedInboundContext({
|
|
2492
|
+
conversationId: convoId,
|
|
2493
|
+
message: m,
|
|
2494
|
+
senderName: sender,
|
|
2495
|
+
isOwner,
|
|
2496
|
+
activeSelfContextId: payload.activeSelfContextId,
|
|
2497
|
+
selfContexts: payload.selfContexts,
|
|
2498
|
+
provenance: payload.provenance,
|
|
2499
|
+
});
|
|
2500
|
+
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2501
|
+
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
2502
|
+
const selfContexts = hydrated.selfContexts;
|
|
2503
|
+
const replyMedia = await materializePromptReplyContext({
|
|
2504
|
+
replyContext: hydrated.replyContext,
|
|
2505
|
+
agentId,
|
|
2506
|
+
conversationId: convoId,
|
|
2507
|
+
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
2508
|
+
});
|
|
2509
|
+
const replyContext = replyMedia.replyContext;
|
|
2510
|
+
const participantContext = hydrated.participantContext;
|
|
2511
|
+
const autoReply = payload.turnDispatch?.kind === 'run_turn'
|
|
2512
|
+
? {
|
|
2513
|
+
allow: true,
|
|
2514
|
+
reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
|
|
2515
|
+
}
|
|
2516
|
+
: decideAutoReply(participantContext, behavior);
|
|
2517
|
+
if (!autoReply.allow) {
|
|
2518
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
markGroupContextModeUsed(convoId, participantContext.groupContextMode);
|
|
2522
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
2523
|
+
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
2524
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
2525
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
2526
|
+
let session;
|
|
2527
|
+
try {
|
|
2528
|
+
session = await getOrCreateSession(convoId);
|
|
2529
|
+
}
|
|
2530
|
+
catch (error) {
|
|
2531
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2532
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2533
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2534
|
+
await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
|
|
2535
|
+
await sendMessageWithRetry(client, convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
|
|
2536
|
+
messageId: `claude-start-failed-${m.id}`,
|
|
2537
|
+
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2538
|
+
metadata: {
|
|
2539
|
+
turnSemantics: 'turn_complete',
|
|
2540
|
+
replyBehavior: 'suppress_auto_reply',
|
|
2541
|
+
},
|
|
2542
|
+
}).catch(() => { });
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
session.activeSelfContextId = activeSelfContextId;
|
|
2546
|
+
const promptText = buildCanonPrompt({
|
|
2547
|
+
content,
|
|
2548
|
+
conversationId: convoId,
|
|
2549
|
+
participantContext,
|
|
2550
|
+
behavior,
|
|
2551
|
+
selfContexts,
|
|
2552
|
+
activeSelfContextId,
|
|
2553
|
+
provenance: hydrated.provenance,
|
|
2554
|
+
replyContext,
|
|
2555
|
+
message: m,
|
|
2556
|
+
});
|
|
2557
|
+
const messageContent = await buildCanonUserContent({
|
|
2558
|
+
promptText,
|
|
2559
|
+
materialized: [...replyMedia.materialized, ...materialized],
|
|
2560
|
+
});
|
|
2561
|
+
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
2562
|
+
session.enqueueInbound({
|
|
2563
|
+
type: 'user',
|
|
2564
|
+
message: {
|
|
2565
|
+
role: 'user',
|
|
2566
|
+
content: messageContent,
|
|
2567
|
+
},
|
|
2568
|
+
parent_tool_use_id: null,
|
|
2569
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2570
|
+
if (m.id) {
|
|
2571
|
+
saveRuntimeSessionState(runtimeId, {
|
|
2572
|
+
conversationId: convoId,
|
|
2573
|
+
baseCwd: workingDir,
|
|
2574
|
+
lastInboundMessageId: m.id,
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
})();
|
|
2578
|
+
},
|
|
2579
|
+
onMessageDeleted: (payload) => {
|
|
2580
|
+
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
2581
|
+
},
|
|
2582
|
+
onConversationUpdated: (payload) => {
|
|
2583
|
+
handleConversationUpdated(payload);
|
|
2584
|
+
},
|
|
2585
|
+
onConnected: () => {
|
|
2586
|
+
streamConnected = true;
|
|
2587
|
+
void publishRuntimeHeartbeat();
|
|
2588
|
+
console.error('[canon-host] SSE connected');
|
|
2589
|
+
},
|
|
2590
|
+
onDisconnected: () => {
|
|
2591
|
+
streamConnected = false;
|
|
2592
|
+
runtimeState.clearAgentRuntime().catch(() => { });
|
|
2593
|
+
console.error('[canon-host] SSE disconnected');
|
|
2594
|
+
},
|
|
2595
|
+
onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
|
|
2596
|
+
},
|
|
2597
|
+
});
|
|
2598
|
+
stream.start().catch((err) => {
|
|
2599
|
+
console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
|
|
2600
|
+
});
|
|
2601
|
+
// ── Control signal watcher ──
|
|
2602
|
+
// Session control signals (model, mode, effort). A missing/closed session
|
|
2603
|
+
// leaves the node in place; lastSeen still advances, so a stale control is
|
|
2604
|
+
// never replayed once a session exists.
|
|
2605
|
+
async function applyRuntimeSessionControl(event) {
|
|
2606
|
+
const convoId = event.conversationId;
|
|
2607
|
+
const session = sessions.get(convoId);
|
|
2608
|
+
if (!session || session.closed)
|
|
2609
|
+
return { consume: false };
|
|
2610
|
+
const ctrl = event.control;
|
|
2611
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
|
|
2612
|
+
if (ctrl.model && ctrl.model !== session.state.model) {
|
|
2238
2613
|
try {
|
|
2239
|
-
|
|
2614
|
+
await session.query.setModel(ctrl.model);
|
|
2615
|
+
session.state.model = ctrl.model;
|
|
2616
|
+
session.clearRuntimeControlError('model');
|
|
2617
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
|
|
2240
2618
|
}
|
|
2241
|
-
catch (
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2245
|
-
await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
|
|
2246
|
-
await port.sendMessage({
|
|
2247
|
-
conversationId: convoId,
|
|
2248
|
-
text: `I couldn't start a coding session for this workspace: ${userMessage}`,
|
|
2249
|
-
messageId: `claude-start-failed-${m.id}`,
|
|
2250
|
-
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2251
|
-
metadata: {
|
|
2252
|
-
turnSemantics: 'turn_complete',
|
|
2253
|
-
replyBehavior: 'suppress_auto_reply',
|
|
2254
|
-
},
|
|
2255
|
-
}).catch(() => { });
|
|
2256
|
-
return;
|
|
2257
|
-
}
|
|
2258
|
-
session.activeSelfContextId = activeSelfContextId;
|
|
2259
|
-
const promptText = buildCanonPrompt({
|
|
2260
|
-
content,
|
|
2261
|
-
conversationId: convoId,
|
|
2262
|
-
participantContext,
|
|
2263
|
-
behavior,
|
|
2264
|
-
selfContexts,
|
|
2265
|
-
activeSelfContextId,
|
|
2266
|
-
provenance: hydrated.provenance,
|
|
2267
|
-
replyContext,
|
|
2268
|
-
message: m,
|
|
2269
|
-
});
|
|
2270
|
-
const messageContent = await buildCanonUserContent({
|
|
2271
|
-
promptText,
|
|
2272
|
-
materialized: [...replyMedia.materialized, ...materialized],
|
|
2273
|
-
});
|
|
2274
|
-
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
2275
|
-
session.enqueueInbound({
|
|
2276
|
-
type: 'user',
|
|
2277
|
-
message: {
|
|
2278
|
-
role: 'user',
|
|
2279
|
-
content: messageContent,
|
|
2280
|
-
},
|
|
2281
|
-
parent_tool_use_id: null,
|
|
2282
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2283
|
-
if (m.id) {
|
|
2284
|
-
saveRuntimeSessionState(runtimeId, {
|
|
2285
|
-
conversationId: convoId,
|
|
2286
|
-
baseCwd: workingDir,
|
|
2287
|
-
lastInboundMessageId: m.id,
|
|
2288
|
-
});
|
|
2289
|
-
}
|
|
2290
|
-
})();
|
|
2291
|
-
};
|
|
2292
|
-
/**
|
|
2293
|
-
* Stripped HITL control replies that matched no bridge record: the plan
|
|
2294
|
-
* approval flow (host-raised via a plain sendMessage) resolves its
|
|
2295
|
-
* metadata-reply waiter here; anything else is fed to the approval
|
|
2296
|
-
* manager as a late/second-chance consume.
|
|
2297
|
-
*/
|
|
2298
|
-
const handleControlReply = (convoId, m) => {
|
|
2299
|
-
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2300
|
-
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2301
|
-
senderId: m.senderId,
|
|
2302
|
-
metadata: m.metadata,
|
|
2303
|
-
}) ?? false;
|
|
2304
|
-
if (m.metadata?.type === 'approval_reply') {
|
|
2305
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2306
|
-
return;
|
|
2307
|
-
}
|
|
2308
|
-
if (consumed) {
|
|
2309
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2310
|
-
return;
|
|
2619
|
+
catch (err) {
|
|
2620
|
+
session.setRuntimeControlError('model', ctrl.model, err);
|
|
2621
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
|
|
2311
2622
|
}
|
|
2312
2623
|
}
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}]
|
|
2324
|
-
return;
|
|
2325
|
-
}
|
|
2326
|
-
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2327
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2328
|
-
return;
|
|
2624
|
+
const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
|
|
2625
|
+
if (ctrl.permissionMode && !nextPermissionMode) {
|
|
2626
|
+
session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
|
|
2627
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
|
|
2628
|
+
}
|
|
2629
|
+
else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
|
|
2630
|
+
try {
|
|
2631
|
+
await session.query.setPermissionMode(nextPermissionMode);
|
|
2632
|
+
session.state.permissionMode = nextPermissionMode;
|
|
2633
|
+
session.clearRuntimeControlError('permissionMode');
|
|
2634
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
|
|
2329
2635
|
}
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
existingSession.lastActivity = Date.now();
|
|
2334
|
-
resolve(m.metadata);
|
|
2335
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2636
|
+
catch (err) {
|
|
2637
|
+
session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
|
|
2638
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
|
|
2336
2639
|
}
|
|
2337
2640
|
}
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
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') {
|
|
2362
|
-
streamConnected = true;
|
|
2363
|
-
void publishRuntimeHeartbeat();
|
|
2364
|
-
console.error('[canon-host] Canon upstream connected');
|
|
2641
|
+
const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
|
|
2642
|
+
if (nextEffort && nextEffort !== session.state.effort) {
|
|
2643
|
+
try {
|
|
2644
|
+
await applyClaudeEffortLevel(session.query, nextEffort);
|
|
2645
|
+
session.state.effort = nextEffort;
|
|
2646
|
+
session.clearRuntimeControlError('effort');
|
|
2647
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
|
|
2365
2648
|
}
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
console.error(`[canon-host] Canon upstream ${upstream}`);
|
|
2649
|
+
catch (err) {
|
|
2650
|
+
session.setRuntimeControlError('effort', nextEffort, err);
|
|
2651
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
|
|
2370
2652
|
}
|
|
2371
|
-
}
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2653
|
+
}
|
|
2654
|
+
else if (ctrl.effort && !nextEffort) {
|
|
2655
|
+
session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
|
|
2656
|
+
}
|
|
2657
|
+
session.writeState();
|
|
2658
|
+
}
|
|
2659
|
+
// Interrupt/stop-clear signal. Throwing leaves the node in place
|
|
2660
|
+
// (consumeOnError: false); lastSeen has already advanced, so the failed
|
|
2661
|
+
// signal is reported but never retried.
|
|
2662
|
+
async function handleRuntimeControlSignal(event) {
|
|
2377
2663
|
const convoId = event.conversationId;
|
|
2378
2664
|
const session = sessions.get(convoId);
|
|
2379
|
-
if (!session || session.closed)
|
|
2380
|
-
|
|
2381
|
-
|
|
2665
|
+
if (!session || session.closed)
|
|
2666
|
+
return { consume: false };
|
|
2667
|
+
if (event.type === 'new_session') {
|
|
2668
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2669
|
+
await resetRuntimeSession(session);
|
|
2382
2670
|
return;
|
|
2383
2671
|
}
|
|
2384
|
-
const
|
|
2385
|
-
|
|
2386
|
-
|
|
2672
|
+
const isActive = session.state.state === 'running';
|
|
2673
|
+
if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2674
|
+
return; // Nothing to interrupt — consume the stale signal
|
|
2675
|
+
}
|
|
2676
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2677
|
+
if (event.type === 'stop_and_drop') {
|
|
2678
|
+
const droppedInputs = session.pendingInputs.splice(0);
|
|
2679
|
+
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2680
|
+
}
|
|
2681
|
+
if (session.activeInput) {
|
|
2682
|
+
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2683
|
+
}
|
|
2684
|
+
session.pendingFinalText = null;
|
|
2685
|
+
session.turnState = 'interrupted';
|
|
2686
|
+
session.clearStreaming().catch(() => { });
|
|
2687
|
+
typingSignals.clear(convoId).catch(() => { });
|
|
2688
|
+
runtimeState.writeTurnState(convoId, {
|
|
2689
|
+
turnId: session.currentTurnId,
|
|
2690
|
+
state: 'interrupted',
|
|
2691
|
+
queueDepth: session.pendingInputs.length,
|
|
2692
|
+
currentSpeakerId: agentId,
|
|
2693
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
2694
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
2695
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2696
|
+
completedAt: { '.sv': 'timestamp' },
|
|
2697
|
+
}).catch(() => { });
|
|
2698
|
+
if (isActive) {
|
|
2699
|
+
await session.query.interrupt();
|
|
2700
|
+
}
|
|
2387
2701
|
}
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2702
|
+
controlPoller.start();
|
|
2703
|
+
// ── Heartbeat (per-session) ──
|
|
2704
|
+
const heartbeat = setInterval(() => {
|
|
2705
|
+
for (const session of sessions.values()) {
|
|
2706
|
+
session.writeState();
|
|
2707
|
+
if (session.state.state !== 'running' && session.state.state !== 'requires_action') {
|
|
2708
|
+
session.writeTurn();
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
void publishRuntimeHeartbeat();
|
|
2712
|
+
}, HEARTBEAT_MS);
|
|
2713
|
+
// ── Idle session cleanup ──
|
|
2714
|
+
const idleCheck = setInterval(() => {
|
|
2715
|
+
const now = Date.now();
|
|
2716
|
+
for (const convoId of [...sessions.keys()]) {
|
|
2717
|
+
const session = sessions.get(convoId);
|
|
2718
|
+
if (!session || session.state.state === 'running')
|
|
2719
|
+
continue;
|
|
2720
|
+
if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
|
|
2721
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Idle timeout`);
|
|
2722
|
+
closeSession(convoId);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
}, IDLE_CHECK_MS);
|
|
2726
|
+
// ── Graceful shutdown ──
|
|
2727
|
+
const shutdown = async () => {
|
|
2728
|
+
console.error('[canon-host] Shutting down...');
|
|
2729
|
+
controlPoller.stop();
|
|
2730
|
+
clearInterval(heartbeat);
|
|
2731
|
+
clearInterval(idleCheck);
|
|
2732
|
+
stream.stop();
|
|
2733
|
+
approvalManager?.dispose();
|
|
2734
|
+
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
2735
|
+
for (const convoId of [...sessions.keys()]) {
|
|
2736
|
+
closeSession(convoId);
|
|
2737
|
+
}
|
|
2738
|
+
markLocalRuntimeStopped(runtimeId);
|
|
2739
|
+
(lockHandle ?? getActiveProfileLock())?.release();
|
|
2740
|
+
process.exit(0);
|
|
2741
|
+
};
|
|
2742
|
+
process.on('SIGINT', shutdown);
|
|
2743
|
+
process.on('SIGTERM', shutdown);
|
|
2744
|
+
process.on('SIGHUP', shutdown);
|
|
2391
2745
|
console.error('[canon-host] Ready — sessions created on demand');
|
|
2392
2746
|
// Keep alive
|
|
2393
2747
|
await new Promise(() => { });
|
|
@@ -2396,7 +2750,7 @@ export async function main() {
|
|
|
2396
2750
|
runCli(import.meta.url, main, (err) => {
|
|
2397
2751
|
const message = err instanceof Error ? err.message : String(err);
|
|
2398
2752
|
console.error(`[canon-host] ${message}`);
|
|
2399
|
-
|
|
2753
|
+
getActiveProfileLock()?.release();
|
|
2400
2754
|
process.exit(1);
|
|
2401
2755
|
}, {
|
|
2402
2756
|
name: 'canon-claude',
|