@canonmsg/claude-code-plugin 0.21.2 → 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/README.md +1 -1
- package/dist/approval-diff.d.ts +1 -1
- package/dist/approval-diff.js +1 -1
- package/dist/approval-server.d.ts +1 -1
- 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 -698
- package/dist/inbound-policy.d.ts +22 -0
- package/dist/inbound-policy.js +46 -0
- package/dist/mcp-args.d.ts +1 -1
- package/dist/register.js +25 -73
- package/dist/server.js +11 -23
- package/dist/session-state.d.ts +6 -55
- package/dist/session-state.js +17 -199
- 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 +8 -10
- 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 }
|
|
@@ -1099,16 +1050,14 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1099
1050
|
cwd,
|
|
1100
1051
|
environment,
|
|
1101
1052
|
query: q,
|
|
1102
|
-
controlApplySeq: new Map(),
|
|
1103
1053
|
sendInput,
|
|
1104
1054
|
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
|
|
1105
|
-
state:
|
|
1106
|
-
model:
|
|
1055
|
+
state: {
|
|
1056
|
+
model: undefined,
|
|
1107
1057
|
permissionMode: config.permissionMode,
|
|
1108
|
-
effort:
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
}),
|
|
1058
|
+
effort: undefined,
|
|
1059
|
+
state: 'idle',
|
|
1060
|
+
},
|
|
1112
1061
|
availableModels: config.availableModels,
|
|
1113
1062
|
streamingText: '',
|
|
1114
1063
|
streamingTimer: null,
|
|
@@ -1140,92 +1089,155 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1140
1089
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1141
1090
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1142
1091
|
};
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
+
},
|
|
1164
1114
|
});
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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
|
+
}
|
|
1169
1126
|
function isActiveTurnInterrupted() {
|
|
1170
1127
|
const turnKey = session.activeInput?.turnKey;
|
|
1171
1128
|
return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
|
|
1172
1129
|
}
|
|
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
|
-
|
|
1206
|
-
|
|
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({
|
|
1207
1194
|
turn,
|
|
1208
|
-
finalizedTurnKeys:
|
|
1209
|
-
interruptedTurnKeys:
|
|
1210
|
-
})
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
const uploaded = await port.uploadMedia({
|
|
1219
|
-
path: file.path,
|
|
1220
|
-
mime: inferUploadMimeType(file.path),
|
|
1221
|
-
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,
|
|
1222
1205
|
conversationId,
|
|
1206
|
+
turnKey: deliverTurn.turnKey,
|
|
1223
1207
|
});
|
|
1224
|
-
|
|
1225
|
-
conversationId,
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
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, '', {
|
|
1229
1241
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1230
1242
|
metadata: buildArtifactMediaMetadata(),
|
|
1231
1243
|
});
|
|
@@ -1262,6 +1274,138 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1262
1274
|
await routeWorkspaceGeneratedArtifacts(input);
|
|
1263
1275
|
}
|
|
1264
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
|
+
}
|
|
1265
1409
|
async function prepareArtifactsForInput(input) {
|
|
1266
1410
|
if (input.kind !== 'canon')
|
|
1267
1411
|
return;
|
|
@@ -1277,8 +1421,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1277
1421
|
return;
|
|
1278
1422
|
}
|
|
1279
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
|
+
}
|
|
1280
1440
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
1281
|
-
|
|
1441
|
+
session.lastActivity = Date.now();
|
|
1442
|
+
const input = createClaudeInputEnvelope({
|
|
1282
1443
|
kind: 'canon',
|
|
1283
1444
|
msg,
|
|
1284
1445
|
intent,
|
|
@@ -1286,11 +1447,35 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1286
1447
|
markAccepted,
|
|
1287
1448
|
isOwnerTurn,
|
|
1288
1449
|
artifactRoutingMode,
|
|
1289
|
-
})
|
|
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);
|
|
1290
1476
|
};
|
|
1291
1477
|
writeTurn();
|
|
1292
1478
|
// ── Process SDK output messages ──
|
|
1293
|
-
let assistantStreamOrdinal = 0;
|
|
1294
1479
|
const processMessages = async () => {
|
|
1295
1480
|
try {
|
|
1296
1481
|
for await (const msg of q) {
|
|
@@ -1321,10 +1506,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1321
1506
|
stopVisibleWorkSignal();
|
|
1322
1507
|
break;
|
|
1323
1508
|
}
|
|
1324
|
-
if (event?.type === '
|
|
1325
|
-
assistantStreamOrdinal += 1;
|
|
1326
|
-
}
|
|
1327
|
-
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') {
|
|
1328
1510
|
session.toolInProgress = true;
|
|
1329
1511
|
session.turnState = 'tool';
|
|
1330
1512
|
streamingOutput.addBlock({
|
|
@@ -1353,35 +1535,12 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1353
1535
|
stopVisibleWorkSignal();
|
|
1354
1536
|
writeTurn();
|
|
1355
1537
|
const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
|
|
1356
|
-
|
|
1357
|
-
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
|
|
1538
|
+
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
|
|
1358
1539
|
}
|
|
1359
1540
|
break;
|
|
1360
1541
|
}
|
|
1361
1542
|
case 'system': {
|
|
1362
1543
|
const subtype = msg.subtype;
|
|
1363
|
-
if (subtype === 'task_started'
|
|
1364
|
-
|| subtype === 'task_progress'
|
|
1365
|
-
|| subtype === 'task_updated'
|
|
1366
|
-
|| subtype === 'task_notification') {
|
|
1367
|
-
const activity = mapClaudeSubagentActivity(msg);
|
|
1368
|
-
if (activity && !isActiveTurnInterrupted()) {
|
|
1369
|
-
if (!session.currentTurnId) {
|
|
1370
|
-
session.currentTurnId = randomUUID();
|
|
1371
|
-
session.currentTurnOpenedAt = Date.now();
|
|
1372
|
-
}
|
|
1373
|
-
if (session.state.state === 'running' && session.turnState !== 'streaming') {
|
|
1374
|
-
session.turnState = 'thinking';
|
|
1375
|
-
startVisibleWorkSignal();
|
|
1376
|
-
writeTurn();
|
|
1377
|
-
}
|
|
1378
|
-
await streamingOutput.addBlock(activity).catch(() => { });
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
if (subtype === 'model_refusal_fallback') {
|
|
1382
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
|
|
1383
|
-
+ `${msg.original_model} → ${msg.fallback_model}`);
|
|
1384
|
-
}
|
|
1385
1544
|
if (subtype === 'session_state_changed') {
|
|
1386
1545
|
const state = msg.state;
|
|
1387
1546
|
session.state.state = state;
|
|
@@ -1393,7 +1552,6 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1393
1552
|
}
|
|
1394
1553
|
session.state.contextUsage = undefined;
|
|
1395
1554
|
session.pendingFinalText = null;
|
|
1396
|
-
assistantStreamOrdinal = 0;
|
|
1397
1555
|
session.toolInProgress = false;
|
|
1398
1556
|
session.turnState = 'thinking';
|
|
1399
1557
|
startVisibleWorkSignal();
|
|
@@ -1490,6 +1648,24 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1490
1648
|
const init = async () => {
|
|
1491
1649
|
processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
|
|
1492
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
|
+
}
|
|
1493
1669
|
if (!resumeSessionId) {
|
|
1494
1670
|
// New session — send seed message to activate streaming input mode
|
|
1495
1671
|
sendInput(createClaudeInputEnvelope({
|
|
@@ -1504,32 +1680,25 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1504
1680
|
},
|
|
1505
1681
|
}));
|
|
1506
1682
|
}
|
|
1507
|
-
// Refresh the canonical runtime descriptor
|
|
1508
|
-
// session may be initialized with a selected model, which can narrow the
|
|
1509
|
-
// SDK's cached model list.
|
|
1683
|
+
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1510
1684
|
try {
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
if (discoveredModels.length > 0) {
|
|
1516
|
-
runtimeModels = discoveredModels;
|
|
1517
|
-
}
|
|
1518
|
-
const modelList = resolveClaudeModelOptions({
|
|
1519
|
-
discovered: runtimeModels,
|
|
1685
|
+
const models = await q.supportedModels();
|
|
1686
|
+
const modelList = mergeClaudeModelOptions({
|
|
1687
|
+
previous: runtimeModels,
|
|
1688
|
+
next: toModelOptions(models),
|
|
1520
1689
|
selectedModel: session.state.model,
|
|
1521
1690
|
});
|
|
1522
1691
|
session.availableModels = modelList;
|
|
1692
|
+
runtimeModels = modelList;
|
|
1523
1693
|
onRuntimeDescriptorUpdate({
|
|
1524
1694
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1525
|
-
defaultModel:
|
|
1695
|
+
defaultModel: modelList[0]?.value,
|
|
1526
1696
|
defaultPermissionMode: 'default',
|
|
1527
1697
|
availableModels: modelList,
|
|
1528
1698
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1529
1699
|
availableExecutionModes: config.availableExecutionModes,
|
|
1530
1700
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1531
1701
|
models: modelList,
|
|
1532
|
-
defaults: config.sessionSettings,
|
|
1533
1702
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1534
1703
|
workspaceRoots: workspaceRootMetadata,
|
|
1535
1704
|
executionModes: config.availableExecutionModes,
|
|
@@ -1537,7 +1706,7 @@ function createSession(conversationId, environment, agentId, port, typingSignals
|
|
|
1537
1706
|
}),
|
|
1538
1707
|
});
|
|
1539
1708
|
writeState();
|
|
1540
|
-
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(', ')}`);
|
|
1541
1710
|
}
|
|
1542
1711
|
catch {
|
|
1543
1712
|
writeState();
|
|
@@ -1582,57 +1751,41 @@ export async function main() {
|
|
|
1582
1751
|
for (const warning of workspaceDiscovery.warnings) {
|
|
1583
1752
|
console.error(`[canon-host] ${warning}`);
|
|
1584
1753
|
}
|
|
1585
|
-
const { agentName: profileAgentName, profile,
|
|
1586
|
-
const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
|
|
1587
|
-
activeLockHandle = lockHandle ?? null;
|
|
1754
|
+
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
|
|
1588
1755
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
1589
|
-
// ── Canon
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
'ready',
|
|
1597
|
-
'connectionState',
|
|
1598
|
-
'onMessage',
|
|
1599
|
-
'onMessageDeleted',
|
|
1600
|
-
'onConversationUpdated',
|
|
1601
|
-
'onSessionControl',
|
|
1602
|
-
'onControlSignal',
|
|
1603
|
-
'onControlReply',
|
|
1604
|
-
]);
|
|
1605
|
-
const bridge = await connectBridge({
|
|
1606
|
-
...(profile ? { profile } : {}),
|
|
1607
|
-
mode: 'auto',
|
|
1608
|
-
...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
|
|
1609
|
-
hello: {
|
|
1610
|
-
clientType: 'claude-code',
|
|
1611
|
-
wantFamilies: ['messages', 'runtime_turn'],
|
|
1612
|
-
},
|
|
1613
|
-
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),
|
|
1614
1763
|
});
|
|
1615
|
-
// The BridgeClient's zod-derived result shapes mirror the core wire types
|
|
1616
|
-
// structurally; the port pins the strong types the host machinery uses.
|
|
1617
|
-
const port = bridge.client;
|
|
1618
|
-
bridge.client.onProtocolError((error) => {
|
|
1619
|
-
console.error(`[canon-host] Bridge protocol error: ${error.message}`);
|
|
1620
|
-
});
|
|
1621
|
-
const typingSignals = createPortTypingSignals(port);
|
|
1622
1764
|
async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
|
|
1623
1765
|
if (!markAccepted || !sourceMessageId)
|
|
1624
1766
|
return;
|
|
1625
|
-
await
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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}`);
|
|
1630
1788
|
}
|
|
1631
|
-
const agentId = bridge.hello.agentId;
|
|
1632
|
-
const ownerId = bridge.hello.agentContext.ownerId ?? null;
|
|
1633
|
-
const ownerName = bridge.hello.agentContext.ownerName ?? null;
|
|
1634
|
-
console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
|
|
1635
|
-
+ `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
|
|
1636
1789
|
const launchArgs = [...process.argv.slice(2)];
|
|
1637
1790
|
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
1638
1791
|
launchArgs.push('--cwd', workingDir);
|
|
@@ -1661,6 +1814,11 @@ export async function main() {
|
|
|
1661
1814
|
lastStartedAt: new Date().toISOString(),
|
|
1662
1815
|
lastHeartbeatAt: new Date().toISOString(),
|
|
1663
1816
|
});
|
|
1817
|
+
const runtimeState = createRuntimeStatePublisher({
|
|
1818
|
+
agentId,
|
|
1819
|
+
clientType: 'claude-code',
|
|
1820
|
+
hostMode: true,
|
|
1821
|
+
});
|
|
1664
1822
|
let streamConnected = false;
|
|
1665
1823
|
const hostAvailableExecutionModes = [
|
|
1666
1824
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1673,60 +1831,23 @@ export async function main() {
|
|
|
1673
1831
|
});
|
|
1674
1832
|
let runtimeDescriptor = {
|
|
1675
1833
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1676
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1677
1834
|
defaultPermissionMode: 'default',
|
|
1678
1835
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1679
1836
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1680
1837
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1681
1838
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1682
1839
|
models: runtimeModels,
|
|
1683
|
-
defaults: profileSessionDefaults,
|
|
1684
1840
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1685
1841
|
workspaceRoots: workspaceRootMetadata,
|
|
1686
1842
|
executionModes: hostAvailableExecutionModes,
|
|
1687
1843
|
presentation: runtimePresentation,
|
|
1688
1844
|
}),
|
|
1689
1845
|
};
|
|
1690
|
-
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
refreshFloorMs: HEARTBEAT_MS,
|
|
1696
|
-
}, { port });
|
|
1697
|
-
const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
|
|
1698
|
-
const publishSessionSnapshotsForConversations = async (conversationIds) => {
|
|
1699
|
-
const ids = Array.from(new Set(conversationIds));
|
|
1700
|
-
if (!streamConnected || ids.length === 0)
|
|
1701
|
-
return;
|
|
1702
|
-
await port.publishHostSessionSnapshots({
|
|
1703
|
-
conversationIds: ids,
|
|
1704
|
-
clientType: 'claude-code',
|
|
1705
|
-
runtime: runtimeDescriptor,
|
|
1706
|
-
workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
|
|
1707
|
-
defaultCwd: workingDir,
|
|
1708
|
-
extraSessionConfigFields: ['permissionMode', 'effort'],
|
|
1709
|
-
liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
|
|
1710
|
-
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
1711
|
-
return [
|
|
1712
|
-
session.conversationId,
|
|
1713
|
-
{
|
|
1714
|
-
...(session.state.model ? { model: session.state.model } : {}),
|
|
1715
|
-
...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
|
|
1716
|
-
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
1717
|
-
...(session.state.runtimeControlValues
|
|
1718
|
-
? { runtimeControlValues: session.state.runtimeControlValues }
|
|
1719
|
-
: {}),
|
|
1720
|
-
...(workspaceId ? { workspaceId } : {}),
|
|
1721
|
-
executionMode: session.environment.mode,
|
|
1722
|
-
executionBranch: session.environment.branch ?? null,
|
|
1723
|
-
},
|
|
1724
|
-
];
|
|
1725
|
-
})),
|
|
1726
|
-
}).catch((error) => {
|
|
1727
|
-
console.error('[canon-host] Failed to publish session snapshots:', error);
|
|
1728
|
-
});
|
|
1729
|
-
};
|
|
1846
|
+
const conversationCache = new Map();
|
|
1847
|
+
const knownConversationIds = new Set();
|
|
1848
|
+
const promptedGroupContextConversationIds = new Set();
|
|
1849
|
+
const pendingMembershipChanges = new Map();
|
|
1850
|
+
let lastKnownConversationRefreshAt = 0;
|
|
1730
1851
|
const publishRuntimeHeartbeat = async () => {
|
|
1731
1852
|
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
1732
1853
|
agentId,
|
|
@@ -1747,20 +1868,42 @@ export async function main() {
|
|
|
1747
1868
|
console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
|
|
1748
1869
|
return runtimeMetadata;
|
|
1749
1870
|
});
|
|
1750
|
-
await publishAgentRuntime(
|
|
1871
|
+
await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
|
|
1751
1872
|
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
1752
1873
|
});
|
|
1753
|
-
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
|
+
});
|
|
1754
1899
|
// Rebuild so the descriptor picks up native slash commands from the
|
|
1755
1900
|
// metadata refresh above — the initial descriptor is built before the
|
|
1756
1901
|
// command inventory has loaded.
|
|
1757
1902
|
if (runtimeDescriptor.runtimeDescriptor) {
|
|
1758
1903
|
runtimeDescriptor = {
|
|
1759
1904
|
...runtimeDescriptor,
|
|
1760
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1761
1905
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1762
1906
|
models: runtimeModels,
|
|
1763
|
-
defaults: profileSessionDefaults,
|
|
1764
1907
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1765
1908
|
workspaceRoots: workspaceRootMetadata,
|
|
1766
1909
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1788,15 +1931,8 @@ export async function main() {
|
|
|
1788
1931
|
executionBranch: session?.environment.branch ?? null,
|
|
1789
1932
|
worktreePath: session?.environment.worktreePath ?? null,
|
|
1790
1933
|
fallbackReason: resolveExecutionFallbackReason(session?.environment),
|
|
1791
|
-
fallbackModel: session?.state.fallbackModel
|
|
1792
|
-
?? profileSessionDefaults.fallbackModel
|
|
1793
|
-
?? null,
|
|
1794
|
-
ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
|
|
1795
|
-
});
|
|
1796
|
-
await port.publishRuntimeStatus({
|
|
1797
|
-
conversationId,
|
|
1798
|
-
presentation: payload,
|
|
1799
1934
|
});
|
|
1935
|
+
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
1800
1936
|
})).catch((error) => {
|
|
1801
1937
|
console.error('[canon-host] Failed to publish runtime info:', error);
|
|
1802
1938
|
});
|
|
@@ -1806,7 +1942,7 @@ export async function main() {
|
|
|
1806
1942
|
runtimeModels = await detectRuntimeModels(workingDir);
|
|
1807
1943
|
runtimeDescriptor = {
|
|
1808
1944
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1809
|
-
defaultModel:
|
|
1945
|
+
defaultModel: runtimeModels[0]?.value,
|
|
1810
1946
|
defaultPermissionMode: 'default',
|
|
1811
1947
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1812
1948
|
availableModels: runtimeModels,
|
|
@@ -1814,7 +1950,6 @@ export async function main() {
|
|
|
1814
1950
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1815
1951
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1816
1952
|
models: runtimeModels,
|
|
1817
|
-
defaults: profileSessionDefaults,
|
|
1818
1953
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1819
1954
|
workspaceRoots: workspaceRootMetadata,
|
|
1820
1955
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1825,14 +1960,12 @@ export async function main() {
|
|
|
1825
1960
|
catch {
|
|
1826
1961
|
runtimeDescriptor = {
|
|
1827
1962
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1828
|
-
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1829
1963
|
defaultPermissionMode: 'default',
|
|
1830
1964
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1831
1965
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1832
1966
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1833
1967
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1834
1968
|
models: runtimeModels,
|
|
1835
|
-
defaults: profileSessionDefaults,
|
|
1836
1969
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1837
1970
|
workspaceRoots: workspaceRootMetadata,
|
|
1838
1971
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1841,12 +1974,14 @@ export async function main() {
|
|
|
1841
1974
|
};
|
|
1842
1975
|
}
|
|
1843
1976
|
try {
|
|
1844
|
-
const convos = await
|
|
1845
|
-
|
|
1977
|
+
const convos = await client.getConversations();
|
|
1978
|
+
lastKnownConversationRefreshAt = Date.now();
|
|
1846
1979
|
for (const conversation of convos) {
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
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(() => { });
|
|
1850
1985
|
typingSignals.clear(conversation.id).catch(() => { });
|
|
1851
1986
|
}
|
|
1852
1987
|
console.error(`[canon-host] ${convos.length} conversations`);
|
|
@@ -1854,68 +1989,64 @@ export async function main() {
|
|
|
1854
1989
|
catch {
|
|
1855
1990
|
console.error('[canon-host] Could not load conversations');
|
|
1856
1991
|
}
|
|
1857
|
-
// ── Session manager
|
|
1858
|
-
const
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
onShutdown: async () => {
|
|
1881
|
-
detachControlNotifications();
|
|
1882
|
-
notifications.detach();
|
|
1883
|
-
approvalManager?.dispose();
|
|
1884
|
-
await port.clearAgentRuntime().catch(() => { });
|
|
1885
|
-
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',
|
|
1886
2015
|
},
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
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);
|
|
1890
2027
|
},
|
|
1891
|
-
});
|
|
1892
|
-
const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
|
|
1893
|
-
// Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
|
|
1894
|
-
const handleRuntimeControlSignal = createRuntimeSignalHandler({
|
|
1895
|
-
agentId,
|
|
1896
|
-
logPrefix: '[canon-host]',
|
|
1897
|
-
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1898
|
-
}, {
|
|
1899
|
-
sessions,
|
|
1900
|
-
port,
|
|
1901
|
-
isSessionRunning: (session) => session.state.state === 'running',
|
|
1902
|
-
interruptRuntime: (session) => session.query.interrupt(),
|
|
1903
|
-
resetSession: (session) => resetRuntimeSession(session),
|
|
1904
|
-
markSessionPendingInputsRejected,
|
|
1905
|
-
});
|
|
1906
|
-
// Control events arrive as bridge notifications — the bridge owns the ONE
|
|
1907
|
-
// authoritative /control consumer for this identity (plan §2); the host's
|
|
1908
|
-
// adaptive poller (active/idle cadence, jitter, per-session baselines) is
|
|
1909
|
-
// deleted with it.
|
|
1910
|
-
const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
|
|
1911
|
-
onSessionControl: applyRuntimeSessionControl,
|
|
1912
|
-
onControlSignal: handleRuntimeControlSignal,
|
|
1913
2028
|
});
|
|
1914
2029
|
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
1915
2030
|
const savedSessionIds = new Map();
|
|
2031
|
+
const { getConversationMeta } = createConversationMetadataLoader({
|
|
2032
|
+
client,
|
|
2033
|
+
conversationCache,
|
|
2034
|
+
});
|
|
1916
2035
|
function resolveWorkspaceIdForBaseCwd(baseCwd) {
|
|
1917
2036
|
return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
|
|
1918
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
|
+
}
|
|
1919
2050
|
async function canRequestCanonApproval(conversationId) {
|
|
1920
2051
|
if (!ownerId)
|
|
1921
2052
|
return false;
|
|
@@ -1923,9 +2054,80 @@ export async function main() {
|
|
|
1923
2054
|
.catch(() => conversationCache.get(conversationId) ?? null);
|
|
1924
2055
|
return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
|
|
1925
2056
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
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);
|
|
1929
2131
|
const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
|
|
1930
2132
|
if (saveResume && session.sdkSessionId) {
|
|
1931
2133
|
savedSessionIds.set(resumeKey, session.sdkSessionId);
|
|
@@ -1957,15 +2159,89 @@ export async function main() {
|
|
|
1957
2159
|
});
|
|
1958
2160
|
}
|
|
1959
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')}`);
|
|
1960
2181
|
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
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);
|
|
1969
2245
|
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
1970
2246
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
1971
2247
|
const environment = prepareConversationEnvironment({
|
|
@@ -1982,7 +2258,7 @@ export async function main() {
|
|
|
1982
2258
|
executionMode: environment.mode,
|
|
1983
2259
|
});
|
|
1984
2260
|
const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
|
|
1985
|
-
const session = createSession(conversationId, environment, agentId,
|
|
2261
|
+
const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
|
|
1986
2262
|
// Called when session processing loop ends — save session ID for resume
|
|
1987
2263
|
const existing = sessions.get(convoId);
|
|
1988
2264
|
if (existing) {
|
|
@@ -2006,17 +2282,17 @@ export async function main() {
|
|
|
2006
2282
|
}
|
|
2007
2283
|
existing?.clearStreaming().catch(() => { });
|
|
2008
2284
|
typingSignals.clear(convoId).catch(() => { });
|
|
2285
|
+
typingSignals.dispose(convoId);
|
|
2009
2286
|
sessions.delete(convoId);
|
|
2010
|
-
|
|
2011
|
-
|
|
2287
|
+
runtimeState.clearSessionState(convoId).catch(() => { });
|
|
2288
|
+
runtimeState.clearTurnState(convoId).catch(() => { });
|
|
2012
2289
|
}, (descriptor) => {
|
|
2013
2290
|
runtimeDescriptor = descriptor;
|
|
2014
2291
|
void publishRuntimeHeartbeat();
|
|
2015
2292
|
}, {
|
|
2016
|
-
model:
|
|
2293
|
+
model: config?.model ?? runtimeModels[0]?.value,
|
|
2017
2294
|
permissionMode: config?.permissionMode ?? 'default',
|
|
2018
|
-
effort:
|
|
2019
|
-
sessionSettings,
|
|
2295
|
+
effort: config?.effort,
|
|
2020
2296
|
availableModels: runtimeModels,
|
|
2021
2297
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
2022
2298
|
runtimePresentation,
|
|
@@ -2026,6 +2302,7 @@ export async function main() {
|
|
|
2026
2302
|
ownerId,
|
|
2027
2303
|
}, resumeId);
|
|
2028
2304
|
sessions.set(conversationId, session);
|
|
2305
|
+
await controlPoller.baseline([conversationId]);
|
|
2029
2306
|
return session;
|
|
2030
2307
|
}
|
|
2031
2308
|
catch (error) {
|
|
@@ -2033,6 +2310,13 @@ export async function main() {
|
|
|
2033
2310
|
throw error;
|
|
2034
2311
|
}
|
|
2035
2312
|
})();
|
|
2313
|
+
pendingSessionCreations.set(conversationId, creation);
|
|
2314
|
+
try {
|
|
2315
|
+
return await creation;
|
|
2316
|
+
}
|
|
2317
|
+
finally {
|
|
2318
|
+
pendingSessionCreations.delete(conversationId);
|
|
2319
|
+
}
|
|
2036
2320
|
}
|
|
2037
2321
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2038
2322
|
const m = input.message;
|
|
@@ -2089,9 +2373,7 @@ export async function main() {
|
|
|
2089
2373
|
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2090
2374
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
|
|
2091
2375
|
await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
|
|
2092
|
-
await
|
|
2093
|
-
conversationId: input.conversationId,
|
|
2094
|
-
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}`, {
|
|
2095
2377
|
messageId: `claude-start-failed-${m.id}`,
|
|
2096
2378
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2097
2379
|
metadata: {
|
|
@@ -2134,261 +2416,332 @@ export async function main() {
|
|
|
2134
2416
|
});
|
|
2135
2417
|
}
|
|
2136
2418
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
await enqueueRecoveredInboundMessage({
|
|
2162
|
-
conversationId: conversation.id,
|
|
2163
|
-
message: message,
|
|
2164
|
-
hydratedPage: recovery.newestPage,
|
|
2165
|
-
});
|
|
2166
|
-
}
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
catch (error) {
|
|
2170
|
-
console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
|
|
2171
|
-
}
|
|
2172
|
-
// ── Inbound Canon events (bridge notifications) ──
|
|
2173
|
-
// The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
|
|
2174
|
-
// HITL control-reply messages (approval_reply / plan_approval_reply) are
|
|
2175
|
-
// STRIPPED from onMessage bridge-side — unmatched ones arrive as
|
|
2176
|
-
// onControlReply and feed the plan-approval waiters below.
|
|
2177
|
-
const handleInboundMessage = (payload) => {
|
|
2178
|
-
const m = payload.message;
|
|
2179
|
-
if (m.senderId === agentId)
|
|
2180
|
-
return;
|
|
2181
|
-
const convoId = payload.conversationId;
|
|
2182
|
-
knownConversationIds.add(convoId);
|
|
2183
|
-
const sender = m.senderName || m.senderId;
|
|
2184
|
-
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2185
|
-
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2186
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2187
|
-
return;
|
|
2188
|
-
}
|
|
2189
|
-
void (async () => {
|
|
2190
|
-
let materialized = [];
|
|
2191
|
-
if (m.id) {
|
|
2192
|
-
try {
|
|
2193
|
-
materialized = await materializeMessageMedia({
|
|
2194
|
-
id: m.id,
|
|
2195
|
-
attachments: m.attachments ?? [],
|
|
2196
|
-
}, { 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
|
+
}
|
|
2197
2443
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
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
|
+
}
|
|
2200
2470
|
}
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
senderName: sender,
|
|
2207
|
-
isOwner,
|
|
2208
|
-
activeSelfContextId: payload.activeSelfContextId,
|
|
2209
|
-
selfContexts: payload.selfContexts,
|
|
2210
|
-
provenance: payload.provenance,
|
|
2211
|
-
});
|
|
2212
|
-
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2213
|
-
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
2214
|
-
const selfContexts = hydrated.selfContexts;
|
|
2215
|
-
const replyMedia = await materializePromptReplyContext({
|
|
2216
|
-
replyContext: hydrated.replyContext,
|
|
2217
|
-
agentId,
|
|
2218
|
-
conversationId: convoId,
|
|
2219
|
-
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
2220
|
-
});
|
|
2221
|
-
const replyContext = replyMedia.replyContext;
|
|
2222
|
-
const participantContext = hydrated.participantContext;
|
|
2223
|
-
const autoReply = payload.turnDispatch?.kind === 'run_turn'
|
|
2224
|
-
? {
|
|
2225
|
-
allow: true,
|
|
2226
|
-
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;
|
|
2227
2476
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
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) {
|
|
2239
2613
|
try {
|
|
2240
|
-
|
|
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}`);
|
|
2241
2618
|
}
|
|
2242
|
-
catch (
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2246
|
-
await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
|
|
2247
|
-
await port.sendMessage({
|
|
2248
|
-
conversationId: convoId,
|
|
2249
|
-
text: `I couldn't start a coding session for this workspace: ${userMessage}`,
|
|
2250
|
-
messageId: `claude-start-failed-${m.id}`,
|
|
2251
|
-
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2252
|
-
metadata: {
|
|
2253
|
-
turnSemantics: 'turn_complete',
|
|
2254
|
-
replyBehavior: 'suppress_auto_reply',
|
|
2255
|
-
},
|
|
2256
|
-
}).catch(() => { });
|
|
2257
|
-
return;
|
|
2258
|
-
}
|
|
2259
|
-
session.activeSelfContextId = activeSelfContextId;
|
|
2260
|
-
const promptText = buildCanonPrompt({
|
|
2261
|
-
content,
|
|
2262
|
-
conversationId: convoId,
|
|
2263
|
-
participantContext,
|
|
2264
|
-
behavior,
|
|
2265
|
-
selfContexts,
|
|
2266
|
-
activeSelfContextId,
|
|
2267
|
-
provenance: hydrated.provenance,
|
|
2268
|
-
replyContext,
|
|
2269
|
-
message: m,
|
|
2270
|
-
});
|
|
2271
|
-
const messageContent = await buildCanonUserContent({
|
|
2272
|
-
promptText,
|
|
2273
|
-
materialized: [...replyMedia.materialized, ...materialized],
|
|
2274
|
-
});
|
|
2275
|
-
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
2276
|
-
session.enqueueInbound({
|
|
2277
|
-
type: 'user',
|
|
2278
|
-
message: {
|
|
2279
|
-
role: 'user',
|
|
2280
|
-
content: messageContent,
|
|
2281
|
-
},
|
|
2282
|
-
parent_tool_use_id: null,
|
|
2283
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2284
|
-
if (m.id) {
|
|
2285
|
-
saveRuntimeSessionState(runtimeId, {
|
|
2286
|
-
conversationId: convoId,
|
|
2287
|
-
baseCwd: workingDir,
|
|
2288
|
-
lastInboundMessageId: m.id,
|
|
2289
|
-
});
|
|
2290
|
-
}
|
|
2291
|
-
})();
|
|
2292
|
-
};
|
|
2293
|
-
/**
|
|
2294
|
-
* Stripped HITL control replies that matched no bridge record: the plan
|
|
2295
|
-
* approval flow (host-raised via a plain sendMessage) resolves its
|
|
2296
|
-
* metadata-reply waiter here; anything else is fed to the approval
|
|
2297
|
-
* manager as a late/second-chance consume.
|
|
2298
|
-
*/
|
|
2299
|
-
const handleControlReply = (convoId, m) => {
|
|
2300
|
-
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2301
|
-
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2302
|
-
senderId: m.senderId,
|
|
2303
|
-
metadata: m.metadata,
|
|
2304
|
-
}) ?? false;
|
|
2305
|
-
if (m.metadata?.type === 'approval_reply') {
|
|
2306
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2307
|
-
return;
|
|
2308
|
-
}
|
|
2309
|
-
if (consumed) {
|
|
2310
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2311
|
-
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);
|
|
2312
2622
|
}
|
|
2313
2623
|
}
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}]
|
|
2325
|
-
return;
|
|
2326
|
-
}
|
|
2327
|
-
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2328
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2329
|
-
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}`);
|
|
2330
2635
|
}
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
existingSession.lastActivity = Date.now();
|
|
2335
|
-
resolve(m.metadata);
|
|
2336
|
-
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);
|
|
2337
2639
|
}
|
|
2338
2640
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
2347
|
-
},
|
|
2348
|
-
onConversationUpdated: (params) => {
|
|
2349
|
-
handleConversationUpdated(params);
|
|
2350
|
-
},
|
|
2351
|
-
onControlReply: (params) => {
|
|
2352
|
-
const payload = params;
|
|
2353
|
-
handleControlReply(payload.conversationId, payload.message);
|
|
2354
|
-
},
|
|
2355
|
-
ready: () => {
|
|
2356
|
-
streamConnected = true;
|
|
2357
|
-
void publishRuntimeHeartbeat();
|
|
2358
|
-
console.error('[canon-host] Bridge ready');
|
|
2359
|
-
},
|
|
2360
|
-
connectionState: (params) => {
|
|
2361
|
-
const { upstream } = params;
|
|
2362
|
-
if (upstream === 'connected') {
|
|
2363
|
-
streamConnected = true;
|
|
2364
|
-
void publishRuntimeHeartbeat();
|
|
2365
|
-
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}`);
|
|
2366
2648
|
}
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
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);
|
|
2371
2652
|
}
|
|
2372
|
-
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
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) {
|
|
2378
2663
|
const convoId = event.conversationId;
|
|
2379
2664
|
const session = sessions.get(convoId);
|
|
2380
|
-
if (!session || session.closed)
|
|
2381
|
-
|
|
2382
|
-
|
|
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);
|
|
2383
2670
|
return;
|
|
2384
2671
|
}
|
|
2385
|
-
const
|
|
2386
|
-
|
|
2387
|
-
|
|
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
|
+
}
|
|
2388
2701
|
}
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
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);
|
|
2392
2745
|
console.error('[canon-host] Ready — sessions created on demand');
|
|
2393
2746
|
// Keep alive
|
|
2394
2747
|
await new Promise(() => { });
|
|
@@ -2397,7 +2750,7 @@ export async function main() {
|
|
|
2397
2750
|
runCli(import.meta.url, main, (err) => {
|
|
2398
2751
|
const message = err instanceof Error ? err.message : String(err);
|
|
2399
2752
|
console.error(`[canon-host] ${message}`);
|
|
2400
|
-
|
|
2753
|
+
getActiveProfileLock()?.release();
|
|
2401
2754
|
process.exit(1);
|
|
2402
2755
|
}, {
|
|
2403
2756
|
name: 'canon-claude',
|