@canonmsg/claude-code-plugin 0.19.6 → 0.21.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/bridge-bin.d.ts +14 -0
- package/dist/bridge-bin.js +27 -0
- package/dist/cli-entry.d.ts +2 -2
- package/dist/cli-entry.js +1 -1
- package/dist/host.d.ts +1 -1
- package/dist/host.js +628 -925
- package/dist/mcp-args.d.ts +1 -1
- package/dist/register.js +73 -25
- package/dist/server.js +23 -11
- package/dist/session-defaults.d.ts +34 -0
- package/dist/session-defaults.js +123 -0
- package/dist/session-state.d.ts +2 -2
- package/dist/subagent-activity.d.ts +11 -0
- package/dist/subagent-activity.js +51 -0
- package/dist/tool-policy.d.ts +2 -1
- package/package.json +10 -8
- package/dist/inbound-policy.d.ts +0 -22
- package/dist/inbound-policy.js +0 -46
- package/dist/startup-recovery.d.ts +0 -46
- package/dist/startup-recovery.js +0 -59
- package/dist/streaming-buffer.d.ts +0 -16
- package/dist/streaming-buffer.js +0 -52
package/dist/host.js
CHANGED
|
@@ -26,15 +26,19 @@ 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 { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia,
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
32
|
-
import { decideAutoReply, } from '
|
|
29
|
+
import { inferUploadMimeType, isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
30
|
+
import { resolvePackagedBridgeBin } from './bridge-bin.js';
|
|
31
|
+
import { connectBridge } from '@canonmsg/framework';
|
|
32
|
+
import { STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, attachHostControlNotifications, captureTurnArtifactSnapshot, collectMissedInboundMessages, collectTurnArtifacts, createBufferedNotificationSource, createConversationDirectory, createFinalDelivery, createHitlWaiters, createPortApprovalClient, createPortTypingSignals, createRuntimeSignalHandler, createRuntimeWriters, createSessionLifecycle, createTurnQueue, decideAutoReply, } from '@canonmsg/agent-host';
|
|
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';
|
|
33
36
|
import { runCli } from './cli-entry.js';
|
|
34
|
-
import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
35
37
|
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
36
38
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
37
39
|
import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
|
|
40
|
+
import { claudeThinkingToFlagValue, claudeThinkingToConfig, parseClaudeSessionDefaults, parseClaudeThinkingMode, parseClaudeUltracodeMode, resolveEffectiveSessionSettings, } from './session-defaults.js';
|
|
41
|
+
import { mapClaudeSubagentActivity } from './subagent-activity.js';
|
|
38
42
|
function parseRuntimeVisibilityPreset(value) {
|
|
39
43
|
return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
|
|
40
44
|
}
|
|
@@ -73,6 +77,11 @@ Keep this terminal open while you want Canon to reach the agent. Closing it,
|
|
|
73
77
|
logging out, rebooting, or sleeping long enough to stop the process takes the
|
|
74
78
|
local agent offline until you revive it. Docs:
|
|
75
79
|
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;
|
|
76
85
|
let workingDir = process.cwd();
|
|
77
86
|
let workspaceOptions = [];
|
|
78
87
|
let workspaceRoots = [];
|
|
@@ -80,6 +89,29 @@ let workspaceRootMetadata = [];
|
|
|
80
89
|
let runtimeModels = [];
|
|
81
90
|
const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
82
91
|
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
|
+
];
|
|
83
115
|
let runtimeMetadata = {
|
|
84
116
|
account: null,
|
|
85
117
|
mcpServers: [],
|
|
@@ -204,15 +236,40 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
204
236
|
RUNTIME_STOP_AND_DROP_ACTION,
|
|
205
237
|
];
|
|
206
238
|
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
|
+
];
|
|
207
261
|
return buildFirstPartyCodingRuntimeDescriptor({
|
|
208
262
|
clientType: 'claude-code',
|
|
209
263
|
models: input.models,
|
|
264
|
+
defaultModel: input.defaults?.model ?? input.models[0]?.value ?? null,
|
|
210
265
|
workspaces: input.workspaces,
|
|
211
266
|
workspaceRoots: input.workspaceRoots,
|
|
212
267
|
executionModes: input.executionModes,
|
|
213
268
|
permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
|
|
214
269
|
defaultPermissionMode: 'default',
|
|
215
270
|
effortOptions: [...CLAUDE_EFFORT_OPTIONS],
|
|
271
|
+
defaultEffort: input.defaults?.effort,
|
|
272
|
+
extraRuntimeControls,
|
|
216
273
|
streamingTextMode: 'delta',
|
|
217
274
|
turnModes: [
|
|
218
275
|
{
|
|
@@ -300,15 +357,15 @@ function toModelOptions(models) {
|
|
|
300
357
|
label: model.displayName,
|
|
301
358
|
}));
|
|
302
359
|
}
|
|
303
|
-
async function publishAgentRuntime(
|
|
304
|
-
await publishHostAgentRuntime(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const config = await loadHostSessionConfig({
|
|
308
|
-
conversationId,
|
|
309
|
-
agentId,
|
|
310
|
-
extraStringFields: ['permissionMode', 'effort'],
|
|
360
|
+
async function publishAgentRuntime(port, runtime) {
|
|
361
|
+
await port.publishHostAgentRuntime({
|
|
362
|
+
clientType: 'claude-code',
|
|
363
|
+
runtime: runtime,
|
|
311
364
|
});
|
|
365
|
+
}
|
|
366
|
+
async function loadSessionConfig(port, conversationId) {
|
|
367
|
+
const raw = await port.getSessionConfig({ conversationId });
|
|
368
|
+
const config = readHostSessionConfig(raw, ['permissionMode', 'effort']);
|
|
312
369
|
return {
|
|
313
370
|
...config,
|
|
314
371
|
permissionMode: config?.permissionMode
|
|
@@ -423,9 +480,6 @@ async function refreshClaudeRuntimeMetadata(input) {
|
|
|
423
480
|
});
|
|
424
481
|
return runtimeMetadataRefresh;
|
|
425
482
|
}
|
|
426
|
-
function sleep(ms) {
|
|
427
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
428
|
-
}
|
|
429
483
|
function mapClaudeQuestions(value) {
|
|
430
484
|
if (!Array.isArray(value))
|
|
431
485
|
return undefined;
|
|
@@ -557,13 +611,22 @@ function buildClaudeRuntimeInfo(input) {
|
|
|
557
611
|
tier: 'diagnostic',
|
|
558
612
|
}
|
|
559
613
|
: null,
|
|
560
|
-
|
|
614
|
+
input.fallbackModel
|
|
561
615
|
? {
|
|
562
616
|
id: 'fallbackModel',
|
|
563
617
|
label: 'Fallback model',
|
|
564
|
-
value:
|
|
618
|
+
value: input.fallbackModel,
|
|
565
619
|
tier: 'diagnostic',
|
|
566
|
-
source: '
|
|
620
|
+
source: 'applied',
|
|
621
|
+
}
|
|
622
|
+
: null,
|
|
623
|
+
input.ultracodeEnabled
|
|
624
|
+
? {
|
|
625
|
+
id: 'ultracode',
|
|
626
|
+
label: 'Ultracode',
|
|
627
|
+
value: 'Enabled',
|
|
628
|
+
tier: 'detail',
|
|
629
|
+
source: 'applied',
|
|
567
630
|
}
|
|
568
631
|
: null,
|
|
569
632
|
{
|
|
@@ -656,9 +719,6 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
656
719
|
const STREAMING_THROTTLE_MS = 250;
|
|
657
720
|
const HEARTBEAT_MS = 30_000;
|
|
658
721
|
const IDLE_CHECK_MS = 60_000;
|
|
659
|
-
const CONTROL_POLL_MS = 2000;
|
|
660
|
-
const IDLE_CONTROL_POLL_MS = 10_000;
|
|
661
|
-
const CONTROL_POLL_JITTER_MS = 1_000;
|
|
662
722
|
const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
663
723
|
const MAX_FINAL_DELIVERY_RETRIES = 1;
|
|
664
724
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
@@ -727,7 +787,7 @@ async function buildCanonUserContent(input) {
|
|
|
727
787
|
];
|
|
728
788
|
}
|
|
729
789
|
// ── Session factory ─────────────────────────────────────────────────
|
|
730
|
-
function createSession(conversationId, environment, agentId,
|
|
790
|
+
function createSession(conversationId, environment, agentId, port, typingSignals, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
731
791
|
const { cwd } = environment;
|
|
732
792
|
const cardResponseUserId = config.ownerId ?? undefined;
|
|
733
793
|
const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
|
|
@@ -787,48 +847,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
787
847
|
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
788
848
|
let session;
|
|
789
849
|
const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
type,
|
|
800
|
-
requestId,
|
|
801
|
-
resolve: (metadata) => {
|
|
802
|
-
clearTimeout(timer);
|
|
803
|
-
resolve(metadata);
|
|
804
|
-
},
|
|
805
|
-
};
|
|
806
|
-
});
|
|
807
|
-
}
|
|
808
|
-
async function waitForRuntimeInputResponse(input) {
|
|
809
|
-
session.lastActivity = Date.now();
|
|
810
|
-
while (Date.now() < input.expiresAt) {
|
|
811
|
-
const response = await client.consumeRuntimeInputResponse({
|
|
812
|
-
conversationId,
|
|
813
|
-
inputId: input.inputId,
|
|
814
|
-
}).catch(() => null);
|
|
815
|
-
if (response?.status === 'submitted') {
|
|
816
|
-
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
817
|
-
}
|
|
818
|
-
if (response?.status === 'cancelled' || response?.status === 'timeout') {
|
|
819
|
-
return { status: response.status };
|
|
820
|
-
}
|
|
821
|
-
await sleep(1_000);
|
|
822
|
-
}
|
|
823
|
-
const response = await client.consumeRuntimeInputResponse({
|
|
824
|
-
conversationId,
|
|
825
|
-
inputId: input.inputId,
|
|
826
|
-
}).catch(() => null);
|
|
827
|
-
if (response?.status === 'submitted') {
|
|
828
|
-
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
829
|
-
}
|
|
830
|
-
return { status: 'timeout' };
|
|
831
|
-
}
|
|
850
|
+
// ── HITL waiters (generic skeleton from @canonmsg/agent-host) ──
|
|
851
|
+
const hitlWaiters = createHitlWaiters({
|
|
852
|
+
conversationId,
|
|
853
|
+
replyTimeoutMs: REPLY_TIMEOUT_MS,
|
|
854
|
+
}, {
|
|
855
|
+
session: () => session,
|
|
856
|
+
port,
|
|
857
|
+
});
|
|
858
|
+
const { waitForReply, waitForRuntimeInputResponse } = hitlWaiters;
|
|
832
859
|
function buildArtifactMediaMetadata() {
|
|
833
860
|
return {
|
|
834
861
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
@@ -840,6 +867,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
840
867
|
};
|
|
841
868
|
}
|
|
842
869
|
const claudeCliPath = resolveClaudeCliPath();
|
|
870
|
+
const initialThinkingConfig = claudeThinkingToConfig(config.sessionSettings.thinking);
|
|
843
871
|
const queryOptions = {
|
|
844
872
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
845
873
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
@@ -849,7 +877,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
849
877
|
includePartialMessages: true,
|
|
850
878
|
maxTurns: 200,
|
|
851
879
|
...(config.effort ? { effort: config.effort } : {}),
|
|
852
|
-
|
|
880
|
+
...(initialThinkingConfig ? { thinking: initialThinkingConfig } : {}),
|
|
881
|
+
...(config.sessionSettings.fallbackModel ? { fallbackModel: config.sessionSettings.fallbackModel } : {}),
|
|
882
|
+
agentProgressSummaries: config.sessionSettings.agentProgressSummaries ?? true,
|
|
883
|
+
...(config.sessionSettings.agents ? { agents: config.sessionSettings.agents } : {}),
|
|
884
|
+
settings: {
|
|
885
|
+
forceLoginMethod: 'claudeai',
|
|
886
|
+
...(config.sessionSettings.ultracode
|
|
887
|
+
? { ultracode: config.sessionSettings.ultracode === 'on' }
|
|
888
|
+
: {}),
|
|
889
|
+
},
|
|
853
890
|
settingSources: ['project', 'local'],
|
|
854
891
|
...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
|
|
855
892
|
stderr: (data) => {
|
|
@@ -864,25 +901,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
864
901
|
const questionId = randomUUID();
|
|
865
902
|
const questions = mapClaudeQuestions(input.questions);
|
|
866
903
|
const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
|
|
867
|
-
await
|
|
904
|
+
await port.requestInput({
|
|
868
905
|
conversationId,
|
|
869
906
|
inputId: questionId,
|
|
870
907
|
kind: 'clarify',
|
|
871
|
-
expiresAt,
|
|
908
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
872
909
|
title: 'Claude needs input',
|
|
873
910
|
prompt: questions?.length
|
|
874
911
|
? 'Claude needs your input to continue.'
|
|
875
912
|
: 'Claude needs input to continue.',
|
|
876
|
-
...(questions
|
|
913
|
+
...(questions
|
|
914
|
+
? { questions: questions }
|
|
915
|
+
: { questions: [] }),
|
|
877
916
|
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
878
917
|
...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
|
|
879
918
|
native: {
|
|
880
919
|
runtime: 'claude-code',
|
|
881
920
|
method: 'AskUserQuestion',
|
|
882
921
|
requestId: questionId,
|
|
883
|
-
turnId: session.currentTurnId
|
|
922
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
884
923
|
},
|
|
885
|
-
turnId: session.currentTurnId
|
|
924
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
886
925
|
});
|
|
887
926
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
888
927
|
const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
|
|
@@ -937,7 +976,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
937
976
|
...(cardResponseUser ?? {}),
|
|
938
977
|
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
939
978
|
});
|
|
940
|
-
await
|
|
979
|
+
await port.sendMessage({
|
|
980
|
+
conversationId,
|
|
981
|
+
text: planApproval.text,
|
|
941
982
|
messageId: `claude-plan-${planId}`,
|
|
942
983
|
...(session.activeSelfContextId
|
|
943
984
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -1057,6 +1098,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1057
1098
|
model: undefined,
|
|
1058
1099
|
permissionMode: config.permissionMode,
|
|
1059
1100
|
effort: undefined,
|
|
1101
|
+
fallbackModel: config.sessionSettings.fallbackModel,
|
|
1102
|
+
runtimeControlValues: config.sessionSettings.runtimeControlValues,
|
|
1060
1103
|
state: 'idle',
|
|
1061
1104
|
},
|
|
1062
1105
|
availableModels: config.availableModels,
|
|
@@ -1090,155 +1133,94 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1090
1133
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1091
1134
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1092
1135
|
};
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
status: snapshot.status,
|
|
1106
|
-
messageId: turnId,
|
|
1107
|
-
turnId,
|
|
1108
|
-
blocks,
|
|
1109
|
-
});
|
|
1110
|
-
},
|
|
1111
|
-
clearSnapshot: () => {
|
|
1112
|
-
session.streamingText = '';
|
|
1113
|
-
return runtimeState.clearStreaming(conversationId);
|
|
1136
|
+
// ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
|
|
1137
|
+
const writers = createRuntimeWriters({
|
|
1138
|
+
conversationId,
|
|
1139
|
+
agentId,
|
|
1140
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1141
|
+
fallbackTurnId: `claude-${conversationId}`,
|
|
1142
|
+
streamingThrottleMs: STREAMING_THROTTLE_MS,
|
|
1143
|
+
requestedControls: {
|
|
1144
|
+
model: config.model,
|
|
1145
|
+
permissionMode: config.permissionMode,
|
|
1146
|
+
effort: config.effort,
|
|
1147
|
+
runtimeControlValues: config.sessionSettings.runtimeControlValues,
|
|
1114
1148
|
},
|
|
1149
|
+
}, {
|
|
1150
|
+
session: () => session,
|
|
1151
|
+
port,
|
|
1152
|
+
buildSessionStateExtras: (writerSession) => ({
|
|
1153
|
+
cwd,
|
|
1154
|
+
executionMode: writerSession.environment.mode,
|
|
1155
|
+
...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
|
|
1156
|
+
...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
|
|
1157
|
+
...(resolveExecutionFallbackReason(writerSession.environment)
|
|
1158
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
|
|
1159
|
+
: {}),
|
|
1160
|
+
}),
|
|
1161
|
+
formatControlError: formatClaudeControlError,
|
|
1115
1162
|
});
|
|
1116
|
-
|
|
1117
|
-
function flushStreaming() {
|
|
1118
|
-
streamingOutput.flush().catch(() => { });
|
|
1119
|
-
}
|
|
1120
|
-
function onStreamDelta(text, segmentId) {
|
|
1121
|
-
if (segmentId) {
|
|
1122
|
-
streamingOutput.appendTextSegmentDelta(segmentId, text);
|
|
1123
|
-
return;
|
|
1124
|
-
}
|
|
1125
|
-
streamingOutput.appendDelta(text);
|
|
1126
|
-
}
|
|
1163
|
+
const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
|
|
1127
1164
|
function isActiveTurnInterrupted() {
|
|
1128
1165
|
const turnKey = session.activeInput?.turnKey;
|
|
1129
1166
|
return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
|
|
1130
1167
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
clearStreaming();
|
|
1166
|
-
resetTurnToIdle();
|
|
1167
|
-
}, FINAL_MESSAGE_HANDOFF_MS);
|
|
1168
|
-
}
|
|
1169
|
-
function markPendingFinalDelivery(finalText, turn) {
|
|
1170
|
-
clearIdleResetTimer();
|
|
1171
|
-
const retryCount = session.pendingFinalDelivery?.turnKey === turn.turnKey
|
|
1172
|
-
? session.pendingFinalDelivery.retryCount
|
|
1173
|
-
: 0;
|
|
1174
|
-
session.pendingFinalText = finalText;
|
|
1175
|
-
session.pendingFinalDelivery = {
|
|
1176
|
-
turnKey: turn.turnKey,
|
|
1177
|
-
text: finalText,
|
|
1178
|
-
retryCount,
|
|
1179
|
-
messageId: buildClaudeFinalMessageId({
|
|
1180
|
-
agentId,
|
|
1181
|
-
conversationId,
|
|
1182
|
-
turnKey: turn.turnKey,
|
|
1183
|
-
}),
|
|
1184
|
-
};
|
|
1185
|
-
session.state.state = 'running';
|
|
1186
|
-
session.turnState = 'streaming';
|
|
1187
|
-
stopVisibleWorkSignal();
|
|
1188
|
-
streamingOutput.replaceSnapshot(finalText, 'streaming').catch(() => { });
|
|
1189
|
-
writeState();
|
|
1190
|
-
writeTurn();
|
|
1191
|
-
typingSignals.start(conversationId, 'typing').catch(() => { });
|
|
1192
|
-
}
|
|
1193
|
-
async function deliverFinalReply(finalText, turn = session.activeInput) {
|
|
1194
|
-
if (!shouldDeliverClaudeFinal({
|
|
1168
|
+
// ── Turn queue + final delivery (generic skeleton from @canonmsg/agent-host) ──
|
|
1169
|
+
const turnQueue = createTurnQueue({ conversationId }, {
|
|
1170
|
+
session: () => session,
|
|
1171
|
+
isSessionRunning: (queueSession) => queueSession.state.state === 'running',
|
|
1172
|
+
markSessionRunning: (queueSession) => { queueSession.state.state = 'running'; },
|
|
1173
|
+
prepareInput: (input) => prepareArtifactsForInput(input),
|
|
1174
|
+
sendInput: (input) => sendInput(input),
|
|
1175
|
+
interruptRuntime: (queueSession) => queueSession.query.interrupt(),
|
|
1176
|
+
port,
|
|
1177
|
+
writers: {
|
|
1178
|
+
writeTurn,
|
|
1179
|
+
stopVisibleWorkSignal,
|
|
1180
|
+
clearStreaming,
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
1183
|
+
const { startCanonInput, drainPendingInput, markQueuedMessageAccepted } = turnQueue;
|
|
1184
|
+
const finalDelivery = createFinalDelivery({
|
|
1185
|
+
conversationId,
|
|
1186
|
+
logPrefix: '[canon-host]',
|
|
1187
|
+
delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
|
|
1188
|
+
}, {
|
|
1189
|
+
session: () => session,
|
|
1190
|
+
port,
|
|
1191
|
+
typingSignals,
|
|
1192
|
+
writers: {
|
|
1193
|
+
writeState,
|
|
1194
|
+
writeTurn,
|
|
1195
|
+
stopVisibleWorkSignal,
|
|
1196
|
+
clearStreaming,
|
|
1197
|
+
getFinalTurnTrail,
|
|
1198
|
+
replaceStreamingSnapshot: (text, status) => streamingOutput.replaceSnapshot(text, status),
|
|
1199
|
+
},
|
|
1200
|
+
resetCompletedTurnState: (deliverySession) => resetClaudeCompletedTurnState(deliverySession),
|
|
1201
|
+
shouldDeliverFinal: (turn, deliverySession) => shouldDeliverClaudeFinal({
|
|
1195
1202
|
turn,
|
|
1196
|
-
finalizedTurnKeys:
|
|
1197
|
-
interruptedTurnKeys:
|
|
1198
|
-
})
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1203
|
+
finalizedTurnKeys: deliverySession.finalizedTurnKeys,
|
|
1204
|
+
interruptedTurnKeys: deliverySession.interruptedTurnKeys,
|
|
1205
|
+
}),
|
|
1206
|
+
buildFinalMessageId: (turnKey) => buildClaudeFinalMessageId({ agentId, conversationId, turnKey }),
|
|
1207
|
+
drainPendingInput: () => drainPendingInput(),
|
|
1208
|
+
});
|
|
1209
|
+
const { clearIdleResetTimer, clearFinalDeliveryTimer, resetTurnToIdle, scheduleFinalHandoffReset, markPendingFinalDelivery, deliverFinalReply, scheduleFinalDeliveryRetry, } = finalDelivery;
|
|
1210
|
+
async function sendTurnArtifactFile(file) {
|
|
1211
|
+
// Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
|
|
1212
|
+
// the upload; then send the permanent message with the attachment ref.
|
|
1213
|
+
const uploaded = await port.uploadMedia({
|
|
1214
|
+
path: file.path,
|
|
1215
|
+
mime: inferUploadMimeType(file.path),
|
|
1216
|
+
fileName: file.fileName,
|
|
1206
1217
|
conversationId,
|
|
1207
|
-
turnKey: deliverTurn.turnKey,
|
|
1208
1218
|
});
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
: {}),
|
|
1215
|
-
metadata: {
|
|
1216
|
-
turnId: session.currentTurnId,
|
|
1217
|
-
turnKey: deliverTurn.turnKey,
|
|
1218
|
-
sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
|
|
1219
|
-
turnSemantics: 'turn_complete',
|
|
1220
|
-
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
1221
|
-
...(getFinalTurnTrail().length > 0
|
|
1222
|
-
? { turnTrail: getFinalTurnTrail() }
|
|
1223
|
-
: {}),
|
|
1224
|
-
},
|
|
1225
|
-
});
|
|
1226
|
-
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1227
|
-
session.state.state = 'idle';
|
|
1228
|
-
session.pendingFinalDelivery = null;
|
|
1229
|
-
clearFinalDeliveryTimer();
|
|
1230
|
-
writeState();
|
|
1231
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
|
|
1232
|
-
return true;
|
|
1233
|
-
}
|
|
1234
|
-
catch (err) {
|
|
1235
|
-
markPendingFinalDelivery(finalText, deliverTurn);
|
|
1236
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
|
|
1237
|
-
return false;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
function sendTurnArtifactFile(file) {
|
|
1241
|
-
return sendMediaFileMessage(client, conversationId, file.path, '', {
|
|
1219
|
+
return port.sendMessage({
|
|
1220
|
+
conversationId,
|
|
1221
|
+
text: '',
|
|
1222
|
+
contentType: uploaded.attachment.kind,
|
|
1223
|
+
attachments: [uploaded.attachment],
|
|
1242
1224
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1243
1225
|
metadata: buildArtifactMediaMetadata(),
|
|
1244
1226
|
});
|
|
@@ -1275,138 +1257,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1275
1257
|
await routeWorkspaceGeneratedArtifacts(input);
|
|
1276
1258
|
}
|
|
1277
1259
|
}
|
|
1278
|
-
function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
|
|
1279
|
-
if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
|
|
1280
|
-
return;
|
|
1281
|
-
if (pending.retryCount >= MAX_FINAL_DELIVERY_RETRIES) {
|
|
1282
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply delivery exhausted after bounded retries`);
|
|
1283
|
-
session.pendingFinalDelivery = null;
|
|
1284
|
-
resetTurnToIdle();
|
|
1285
|
-
return;
|
|
1286
|
-
}
|
|
1287
|
-
pending.retryCount += 1;
|
|
1288
|
-
session.pendingFinalDelivery = pending;
|
|
1289
|
-
clearFinalDeliveryTimer();
|
|
1290
|
-
session.finalDeliveryTimer = setTimeout(() => {
|
|
1291
|
-
session.finalDeliveryTimer = null;
|
|
1292
|
-
const retryTurn = session.activeInput?.turnKey === pending.turnKey
|
|
1293
|
-
? session.activeInput
|
|
1294
|
-
: null;
|
|
1295
|
-
void deliverFinalReply(pending.text, retryTurn).then((sent) => {
|
|
1296
|
-
if (sent) {
|
|
1297
|
-
scheduleFinalHandoffReset();
|
|
1298
|
-
return;
|
|
1299
|
-
}
|
|
1300
|
-
scheduleFinalDeliveryRetry();
|
|
1301
|
-
});
|
|
1302
|
-
}, FINAL_DELIVERY_RETRY_MS);
|
|
1303
|
-
}
|
|
1304
|
-
function writeState() {
|
|
1305
|
-
const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
|
|
1306
|
-
? session.runtimeControlErrors
|
|
1307
|
-
: null;
|
|
1308
|
-
const controlState = {};
|
|
1309
|
-
const appliedAt = Date.now();
|
|
1310
|
-
if (session.state.model !== undefined) {
|
|
1311
|
-
controlState.model = { value: session.state.model, source: 'applied', appliedAt };
|
|
1312
|
-
}
|
|
1313
|
-
else if (config.model !== undefined) {
|
|
1314
|
-
controlState.model = { value: config.model, source: 'requested' };
|
|
1315
|
-
}
|
|
1316
|
-
if (session.state.permissionMode !== undefined) {
|
|
1317
|
-
controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
|
|
1318
|
-
}
|
|
1319
|
-
else if (config.permissionMode !== undefined) {
|
|
1320
|
-
controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
|
|
1321
|
-
}
|
|
1322
|
-
if (session.state.effort !== undefined) {
|
|
1323
|
-
controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
|
|
1324
|
-
}
|
|
1325
|
-
else if (config.effort !== undefined) {
|
|
1326
|
-
controlState.effort = { value: config.effort, source: 'requested' };
|
|
1327
|
-
}
|
|
1328
|
-
runtimeState.writeSessionState(conversationId, {
|
|
1329
|
-
model: session.state.model,
|
|
1330
|
-
permissionMode: session.state.permissionMode,
|
|
1331
|
-
effort: session.state.effort,
|
|
1332
|
-
controlState,
|
|
1333
|
-
runtimeControlErrors,
|
|
1334
|
-
contextUsage: session.state.contextUsage,
|
|
1335
|
-
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
1336
|
-
cwd,
|
|
1337
|
-
executionMode: session.environment.mode,
|
|
1338
|
-
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
1339
|
-
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
1340
|
-
...(resolveExecutionFallbackReason(session.environment)
|
|
1341
|
-
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
1342
|
-
: {}),
|
|
1343
|
-
hostMode: true,
|
|
1344
|
-
isActive: true,
|
|
1345
|
-
}).catch(() => { });
|
|
1346
|
-
}
|
|
1347
|
-
function setRuntimeControlError(controlId, value, error) {
|
|
1348
|
-
session.runtimeControlErrors = {
|
|
1349
|
-
...session.runtimeControlErrors,
|
|
1350
|
-
[controlId]: {
|
|
1351
|
-
value,
|
|
1352
|
-
message: formatClaudeControlError(error),
|
|
1353
|
-
updatedAt: Date.now(),
|
|
1354
|
-
},
|
|
1355
|
-
};
|
|
1356
|
-
writeState();
|
|
1357
|
-
}
|
|
1358
|
-
function clearRuntimeControlError(controlId) {
|
|
1359
|
-
if (!(controlId in session.runtimeControlErrors))
|
|
1360
|
-
return;
|
|
1361
|
-
const next = { ...session.runtimeControlErrors };
|
|
1362
|
-
delete next[controlId];
|
|
1363
|
-
session.runtimeControlErrors = next;
|
|
1364
|
-
writeState();
|
|
1365
|
-
}
|
|
1366
|
-
function writeTurn() {
|
|
1367
|
-
const isOpenTurn = session.turnState === 'thinking'
|
|
1368
|
-
|| session.turnState === 'streaming'
|
|
1369
|
-
|| session.turnState === 'tool'
|
|
1370
|
-
|| session.turnState === 'waiting_input';
|
|
1371
|
-
if (isOpenTurn) {
|
|
1372
|
-
session.currentTurnUpdatedAt = Date.now();
|
|
1373
|
-
}
|
|
1374
|
-
runtimeState.writeTurnState(conversationId, {
|
|
1375
|
-
turnId: session.currentTurnId,
|
|
1376
|
-
state: session.turnState,
|
|
1377
|
-
queueDepth: session.pendingInputs.length,
|
|
1378
|
-
currentSpeakerId: agentId,
|
|
1379
|
-
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
1380
|
-
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1381
|
-
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
1382
|
-
...(isOpenTurn && session.currentTurnUpdatedAt ? { turnUpdatedAt: session.currentTurnUpdatedAt } : {}),
|
|
1383
|
-
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
1384
|
-
? { completedAt: { '.sv': 'timestamp' } }
|
|
1385
|
-
: {}),
|
|
1386
|
-
}).catch(() => { });
|
|
1387
|
-
}
|
|
1388
|
-
function refreshVisibleWorkSignal() {
|
|
1389
|
-
if (session.closed)
|
|
1390
|
-
return;
|
|
1391
|
-
if (session.turnState !== 'thinking' && session.turnState !== 'tool')
|
|
1392
|
-
return;
|
|
1393
|
-
typingSignals.start(conversationId, 'thinking').catch(() => { });
|
|
1394
|
-
}
|
|
1395
|
-
function startVisibleWorkSignal() {
|
|
1396
|
-
refreshVisibleWorkSignal();
|
|
1397
|
-
}
|
|
1398
|
-
function stopVisibleWorkSignal() {
|
|
1399
|
-
if (session.typingKeepaliveTimer) {
|
|
1400
|
-
clearInterval(session.typingKeepaliveTimer);
|
|
1401
|
-
session.typingKeepaliveTimer = null;
|
|
1402
|
-
}
|
|
1403
|
-
typingSignals.clear(conversationId).catch(() => { });
|
|
1404
|
-
}
|
|
1405
|
-
async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
|
|
1406
|
-
if (!markAccepted || !sourceMessageId)
|
|
1407
|
-
return;
|
|
1408
|
-
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
1409
|
-
}
|
|
1410
1260
|
async function prepareArtifactsForInput(input) {
|
|
1411
1261
|
if (input.kind !== 'canon')
|
|
1412
1262
|
return;
|
|
@@ -1422,25 +1272,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1422
1272
|
return;
|
|
1423
1273
|
}
|
|
1424
1274
|
}
|
|
1425
|
-
async function startCanonInput(input) {
|
|
1426
|
-
await markQueuedMessageAccepted(input.sourceMessageId, input.markAccepted);
|
|
1427
|
-
await prepareArtifactsForInput(input);
|
|
1428
|
-
session.state.state = 'running';
|
|
1429
|
-
sendInput(input);
|
|
1430
|
-
}
|
|
1431
|
-
function drainPendingInput() {
|
|
1432
|
-
if (session.closed || session.state.state === 'running' || session.pendingInputs.length === 0)
|
|
1433
|
-
return;
|
|
1434
|
-
const next = session.pendingInputs.shift();
|
|
1435
|
-
if (!next)
|
|
1436
|
-
return;
|
|
1437
|
-
session.lastAcceptedIntent = next.intent;
|
|
1438
|
-
writeTurn();
|
|
1439
|
-
void startCanonInput(next);
|
|
1440
|
-
}
|
|
1441
1275
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
1442
|
-
|
|
1443
|
-
const input = createClaudeInputEnvelope({
|
|
1276
|
+
turnQueue.enqueueCanonInput(createClaudeInputEnvelope({
|
|
1444
1277
|
kind: 'canon',
|
|
1445
1278
|
msg,
|
|
1446
1279
|
intent,
|
|
@@ -1448,35 +1281,11 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1448
1281
|
markAccepted,
|
|
1449
1282
|
isOwnerTurn,
|
|
1450
1283
|
artifactRoutingMode,
|
|
1451
|
-
});
|
|
1452
|
-
if (session.pendingFinalDelivery) {
|
|
1453
|
-
session.pendingInputs.push(input);
|
|
1454
|
-
writeTurn();
|
|
1455
|
-
return;
|
|
1456
|
-
}
|
|
1457
|
-
if (session.state.state === 'running' && intent !== 'interrupt') {
|
|
1458
|
-
session.pendingInputs.push(input);
|
|
1459
|
-
writeTurn();
|
|
1460
|
-
return;
|
|
1461
|
-
}
|
|
1462
|
-
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
1463
|
-
if (session.activeInput) {
|
|
1464
|
-
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
1465
|
-
}
|
|
1466
|
-
session.pendingInputs.unshift(input);
|
|
1467
|
-
session.turnState = 'interrupted';
|
|
1468
|
-
stopVisibleWorkSignal();
|
|
1469
|
-
clearStreaming().catch(() => { });
|
|
1470
|
-
writeTurn();
|
|
1471
|
-
void session.query.interrupt().catch(() => { });
|
|
1472
|
-
return;
|
|
1473
|
-
}
|
|
1474
|
-
session.lastAcceptedIntent = intent;
|
|
1475
|
-
writeTurn();
|
|
1476
|
-
void startCanonInput(input);
|
|
1284
|
+
}));
|
|
1477
1285
|
};
|
|
1478
1286
|
writeTurn();
|
|
1479
1287
|
// ── Process SDK output messages ──
|
|
1288
|
+
let assistantStreamOrdinal = 0;
|
|
1480
1289
|
const processMessages = async () => {
|
|
1481
1290
|
try {
|
|
1482
1291
|
for await (const msg of q) {
|
|
@@ -1507,7 +1316,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1507
1316
|
stopVisibleWorkSignal();
|
|
1508
1317
|
break;
|
|
1509
1318
|
}
|
|
1510
|
-
if (event?.type === '
|
|
1319
|
+
if (event?.type === 'message_start') {
|
|
1320
|
+
assistantStreamOrdinal += 1;
|
|
1321
|
+
}
|
|
1322
|
+
else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
1511
1323
|
session.toolInProgress = true;
|
|
1512
1324
|
session.turnState = 'tool';
|
|
1513
1325
|
streamingOutput.addBlock({
|
|
@@ -1536,12 +1348,35 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1536
1348
|
stopVisibleWorkSignal();
|
|
1537
1349
|
writeTurn();
|
|
1538
1350
|
const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
|
|
1539
|
-
|
|
1351
|
+
const messageOrdinal = assistantStreamOrdinal || 1;
|
|
1352
|
+
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${messageOrdinal}:${blockIndex}`);
|
|
1540
1353
|
}
|
|
1541
1354
|
break;
|
|
1542
1355
|
}
|
|
1543
1356
|
case 'system': {
|
|
1544
1357
|
const subtype = msg.subtype;
|
|
1358
|
+
if (subtype === 'task_started'
|
|
1359
|
+
|| subtype === 'task_progress'
|
|
1360
|
+
|| subtype === 'task_updated'
|
|
1361
|
+
|| subtype === 'task_notification') {
|
|
1362
|
+
const activity = mapClaudeSubagentActivity(msg);
|
|
1363
|
+
if (activity && !isActiveTurnInterrupted()) {
|
|
1364
|
+
if (!session.currentTurnId) {
|
|
1365
|
+
session.currentTurnId = randomUUID();
|
|
1366
|
+
session.currentTurnOpenedAt = Date.now();
|
|
1367
|
+
}
|
|
1368
|
+
if (session.state.state === 'running' && session.turnState !== 'streaming') {
|
|
1369
|
+
session.turnState = 'thinking';
|
|
1370
|
+
startVisibleWorkSignal();
|
|
1371
|
+
writeTurn();
|
|
1372
|
+
}
|
|
1373
|
+
await streamingOutput.addBlock(activity).catch(() => { });
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (subtype === 'model_refusal_fallback') {
|
|
1377
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Model refusal fallback: `
|
|
1378
|
+
+ `${msg.original_model} → ${msg.fallback_model}`);
|
|
1379
|
+
}
|
|
1545
1380
|
if (subtype === 'session_state_changed') {
|
|
1546
1381
|
const state = msg.state;
|
|
1547
1382
|
session.state.state = state;
|
|
@@ -1553,6 +1388,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1553
1388
|
}
|
|
1554
1389
|
session.state.contextUsage = undefined;
|
|
1555
1390
|
session.pendingFinalText = null;
|
|
1391
|
+
assistantStreamOrdinal = 0;
|
|
1556
1392
|
session.toolInProgress = false;
|
|
1557
1393
|
session.turnState = 'thinking';
|
|
1558
1394
|
startVisibleWorkSignal();
|
|
@@ -1693,13 +1529,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1693
1529
|
runtimeModels = modelList;
|
|
1694
1530
|
onRuntimeDescriptorUpdate({
|
|
1695
1531
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1696
|
-
defaultModel: modelList[0]?.value,
|
|
1532
|
+
defaultModel: config.sessionSettings.model ?? modelList[0]?.value,
|
|
1697
1533
|
defaultPermissionMode: 'default',
|
|
1698
1534
|
availableModels: modelList,
|
|
1699
1535
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1700
1536
|
availableExecutionModes: config.availableExecutionModes,
|
|
1701
1537
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1702
1538
|
models: modelList,
|
|
1539
|
+
defaults: config.sessionSettings,
|
|
1703
1540
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1704
1541
|
workspaceRoots: workspaceRootMetadata,
|
|
1705
1542
|
executionModes: config.availableExecutionModes,
|
|
@@ -1752,41 +1589,57 @@ export async function main() {
|
|
|
1752
1589
|
for (const warning of workspaceDiscovery.warnings) {
|
|
1753
1590
|
console.error(`[canon-host] ${warning}`);
|
|
1754
1591
|
}
|
|
1755
|
-
const {
|
|
1592
|
+
const { agentName: profileAgentName, profile, sessionDefaults: rawSessionDefaults, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
|
|
1593
|
+
const profileSessionDefaults = parseClaudeSessionDefaults(rawSessionDefaults);
|
|
1594
|
+
activeLockHandle = lockHandle ?? null;
|
|
1756
1595
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
1757
|
-
// ── Canon
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1596
|
+
// ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
|
|
1597
|
+
// One connectBridge per host process: the daemon owns the SSE stream,
|
|
1598
|
+
// RTDB auth, control polling, dedup, and reconnect. Notification handlers
|
|
1599
|
+
// must exist BEFORE the hello response lands (replayed frames dispatch
|
|
1600
|
+
// synchronously), so a buffered source subscribes inside `configure` and
|
|
1601
|
+
// the real handlers attach after the session machinery is built.
|
|
1602
|
+
const notifications = createBufferedNotificationSource([
|
|
1603
|
+
'ready',
|
|
1604
|
+
'connectionState',
|
|
1605
|
+
'onMessage',
|
|
1606
|
+
'onMessageDeleted',
|
|
1607
|
+
'onConversationUpdated',
|
|
1608
|
+
'onSessionControl',
|
|
1609
|
+
'onControlSignal',
|
|
1610
|
+
'onControlReply',
|
|
1611
|
+
]);
|
|
1612
|
+
const bridge = await connectBridge({
|
|
1613
|
+
...(profile ? { profile } : {}),
|
|
1614
|
+
mode: 'auto',
|
|
1615
|
+
...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
|
|
1616
|
+
hello: {
|
|
1617
|
+
clientType: 'claude-code',
|
|
1618
|
+
wantFamilies: ['messages', 'runtime_turn'],
|
|
1619
|
+
},
|
|
1620
|
+
configure: (bridgeClient) => notifications.connect(bridgeClient),
|
|
1621
|
+
});
|
|
1622
|
+
// The BridgeClient's zod-derived result shapes mirror the core wire types
|
|
1623
|
+
// structurally; the port pins the strong types the host machinery uses.
|
|
1624
|
+
const port = bridge.client;
|
|
1625
|
+
bridge.client.onProtocolError((error) => {
|
|
1626
|
+
console.error(`[canon-host] Bridge protocol error: ${error.message}`);
|
|
1764
1627
|
});
|
|
1628
|
+
const typingSignals = createPortTypingSignals(port);
|
|
1765
1629
|
async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
|
|
1766
1630
|
if (!markAccepted || !sourceMessageId)
|
|
1767
1631
|
return;
|
|
1768
|
-
await
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
try {
|
|
1774
|
-
const ctx = await client.getAgentMe();
|
|
1775
|
-
agentId = ctx.agentId;
|
|
1776
|
-
ownerId = ctx.ownerId;
|
|
1777
|
-
ownerName = ctx.ownerName;
|
|
1778
|
-
console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
|
|
1779
|
-
}
|
|
1780
|
-
catch {
|
|
1781
|
-
if (profileAgentId) {
|
|
1782
|
-
agentId = profileAgentId;
|
|
1783
|
-
}
|
|
1784
|
-
else {
|
|
1785
|
-
const auth = await client.getAuthToken();
|
|
1786
|
-
agentId = auth.agentId;
|
|
1787
|
-
}
|
|
1788
|
-
console.error(`[canon-host] Authenticated as ${agentId}`);
|
|
1632
|
+
await port.setDisposition({
|
|
1633
|
+
conversationId,
|
|
1634
|
+
messageId: sourceMessageId,
|
|
1635
|
+
inboundDisposition: 'accepted_now',
|
|
1636
|
+
}).catch(() => { });
|
|
1789
1637
|
}
|
|
1638
|
+
const agentId = bridge.hello.agentId;
|
|
1639
|
+
const ownerId = bridge.hello.agentContext.ownerId ?? null;
|
|
1640
|
+
const ownerName = bridge.hello.agentContext.ownerName ?? null;
|
|
1641
|
+
console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
|
|
1642
|
+
+ `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
|
|
1790
1643
|
const launchArgs = [...process.argv.slice(2)];
|
|
1791
1644
|
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
1792
1645
|
launchArgs.push('--cwd', workingDir);
|
|
@@ -1815,11 +1668,6 @@ export async function main() {
|
|
|
1815
1668
|
lastStartedAt: new Date().toISOString(),
|
|
1816
1669
|
lastHeartbeatAt: new Date().toISOString(),
|
|
1817
1670
|
});
|
|
1818
|
-
const runtimeState = createRuntimeStatePublisher({
|
|
1819
|
-
agentId,
|
|
1820
|
-
clientType: 'claude-code',
|
|
1821
|
-
hostMode: true,
|
|
1822
|
-
});
|
|
1823
1671
|
let streamConnected = false;
|
|
1824
1672
|
const hostAvailableExecutionModes = [
|
|
1825
1673
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1832,23 +1680,28 @@ export async function main() {
|
|
|
1832
1680
|
});
|
|
1833
1681
|
let runtimeDescriptor = {
|
|
1834
1682
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1683
|
+
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1835
1684
|
defaultPermissionMode: 'default',
|
|
1836
1685
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1837
1686
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1838
1687
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1839
1688
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1840
1689
|
models: runtimeModels,
|
|
1690
|
+
defaults: profileSessionDefaults,
|
|
1841
1691
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1842
1692
|
workspaceRoots: workspaceRootMetadata,
|
|
1843
1693
|
executionModes: hostAvailableExecutionModes,
|
|
1844
1694
|
presentation: runtimePresentation,
|
|
1845
1695
|
}),
|
|
1846
1696
|
};
|
|
1847
|
-
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1697
|
+
// ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
|
|
1698
|
+
const directory = createConversationDirectory({
|
|
1699
|
+
agentId,
|
|
1700
|
+
ownerId,
|
|
1701
|
+
ownerName,
|
|
1702
|
+
refreshFloorMs: HEARTBEAT_MS,
|
|
1703
|
+
}, { port });
|
|
1704
|
+
const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
|
|
1852
1705
|
const publishRuntimeHeartbeat = async () => {
|
|
1853
1706
|
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
1854
1707
|
agentId,
|
|
@@ -1869,18 +1722,17 @@ export async function main() {
|
|
|
1869
1722
|
console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
|
|
1870
1723
|
return runtimeMetadata;
|
|
1871
1724
|
});
|
|
1872
|
-
await publishAgentRuntime(
|
|
1725
|
+
await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
|
|
1873
1726
|
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
1874
1727
|
});
|
|
1875
|
-
await publishHostSessionSnapshots({
|
|
1728
|
+
await port.publishHostSessionSnapshots({
|
|
1876
1729
|
conversationIds: Array.from(knownConversationIds),
|
|
1877
|
-
agentId,
|
|
1878
1730
|
clientType: 'claude-code',
|
|
1879
1731
|
runtime: runtimeDescriptor,
|
|
1880
|
-
workspaceOptions,
|
|
1732
|
+
workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
|
|
1881
1733
|
defaultCwd: workingDir,
|
|
1882
1734
|
extraSessionConfigFields: ['permissionMode', 'effort'],
|
|
1883
|
-
liveSessionConfigByConversation:
|
|
1735
|
+
liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
|
|
1884
1736
|
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
1885
1737
|
return [
|
|
1886
1738
|
session.conversationId,
|
|
@@ -1888,6 +1740,9 @@ export async function main() {
|
|
|
1888
1740
|
...(session.state.model ? { model: session.state.model } : {}),
|
|
1889
1741
|
...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
|
|
1890
1742
|
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
1743
|
+
...(session.state.runtimeControlValues
|
|
1744
|
+
? { runtimeControlValues: session.state.runtimeControlValues }
|
|
1745
|
+
: {}),
|
|
1891
1746
|
...(workspaceId ? { workspaceId } : {}),
|
|
1892
1747
|
executionMode: session.environment.mode,
|
|
1893
1748
|
executionBranch: session.environment.branch ?? null,
|
|
@@ -1903,8 +1758,10 @@ export async function main() {
|
|
|
1903
1758
|
if (runtimeDescriptor.runtimeDescriptor) {
|
|
1904
1759
|
runtimeDescriptor = {
|
|
1905
1760
|
...runtimeDescriptor,
|
|
1761
|
+
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1906
1762
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1907
1763
|
models: runtimeModels,
|
|
1764
|
+
defaults: profileSessionDefaults,
|
|
1908
1765
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1909
1766
|
workspaceRoots: workspaceRootMetadata,
|
|
1910
1767
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1932,8 +1789,15 @@ export async function main() {
|
|
|
1932
1789
|
executionBranch: session?.environment.branch ?? null,
|
|
1933
1790
|
worktreePath: session?.environment.worktreePath ?? null,
|
|
1934
1791
|
fallbackReason: resolveExecutionFallbackReason(session?.environment),
|
|
1792
|
+
fallbackModel: session?.state.fallbackModel
|
|
1793
|
+
?? profileSessionDefaults.fallbackModel
|
|
1794
|
+
?? null,
|
|
1795
|
+
ultracodeEnabled: (session?.state.runtimeControlValues?.ultracode ?? profileSessionDefaults.ultracode) === 'on',
|
|
1796
|
+
});
|
|
1797
|
+
await port.publishRuntimeStatus({
|
|
1798
|
+
conversationId,
|
|
1799
|
+
presentation: payload,
|
|
1935
1800
|
});
|
|
1936
|
-
await runtimeState.writeRuntimeInfo(conversationId, payload);
|
|
1937
1801
|
})).catch((error) => {
|
|
1938
1802
|
console.error('[canon-host] Failed to publish runtime info:', error);
|
|
1939
1803
|
});
|
|
@@ -1943,7 +1807,7 @@ export async function main() {
|
|
|
1943
1807
|
runtimeModels = await detectRuntimeModels(workingDir);
|
|
1944
1808
|
runtimeDescriptor = {
|
|
1945
1809
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1946
|
-
defaultModel: runtimeModels[0]?.value,
|
|
1810
|
+
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1947
1811
|
defaultPermissionMode: 'default',
|
|
1948
1812
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1949
1813
|
availableModels: runtimeModels,
|
|
@@ -1951,6 +1815,7 @@ export async function main() {
|
|
|
1951
1815
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1952
1816
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1953
1817
|
models: runtimeModels,
|
|
1818
|
+
defaults: profileSessionDefaults,
|
|
1954
1819
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1955
1820
|
workspaceRoots: workspaceRootMetadata,
|
|
1956
1821
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1961,12 +1826,14 @@ export async function main() {
|
|
|
1961
1826
|
catch {
|
|
1962
1827
|
runtimeDescriptor = {
|
|
1963
1828
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
1829
|
+
defaultModel: profileSessionDefaults.model ?? runtimeModels[0]?.value,
|
|
1964
1830
|
defaultPermissionMode: 'default',
|
|
1965
1831
|
availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
|
|
1966
1832
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1967
1833
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
1968
1834
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1969
1835
|
models: runtimeModels,
|
|
1836
|
+
defaults: profileSessionDefaults,
|
|
1970
1837
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1971
1838
|
workspaceRoots: workspaceRootMetadata,
|
|
1972
1839
|
executionModes: hostAvailableExecutionModes,
|
|
@@ -1975,14 +1842,12 @@ export async function main() {
|
|
|
1975
1842
|
};
|
|
1976
1843
|
}
|
|
1977
1844
|
try {
|
|
1978
|
-
const convos = await
|
|
1979
|
-
|
|
1845
|
+
const convos = await directory.listConversations();
|
|
1846
|
+
directory.primeFromStartup(convos);
|
|
1980
1847
|
for (const conversation of convos) {
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
1985
|
-
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
1848
|
+
port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
|
|
1849
|
+
port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
|
|
1850
|
+
port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
|
|
1986
1851
|
typingSignals.clear(conversation.id).catch(() => { });
|
|
1987
1852
|
}
|
|
1988
1853
|
console.error(`[canon-host] ${convos.length} conversations`);
|
|
@@ -1990,64 +1855,68 @@ export async function main() {
|
|
|
1990
1855
|
catch {
|
|
1991
1856
|
console.error('[canon-host] Could not load conversations');
|
|
1992
1857
|
}
|
|
1993
|
-
// ── Session manager ──
|
|
1994
|
-
const
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
1858
|
+
// ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
|
|
1859
|
+
const approvalManager = ownerId
|
|
1860
|
+
? new ApprovalManager(createPortApprovalClient(port), agentId, ownerId)
|
|
1861
|
+
: null;
|
|
1862
|
+
const lifecycle = createSessionLifecycle({
|
|
1863
|
+
maxSessions: MAX_SESSIONS,
|
|
1864
|
+
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
|
1865
|
+
heartbeatMs: HEARTBEAT_MS,
|
|
1866
|
+
idleCheckMs: IDLE_CHECK_MS,
|
|
1867
|
+
logPrefix: '[canon-host]',
|
|
1868
|
+
}, {
|
|
1869
|
+
knownConversationIds,
|
|
1870
|
+
createSession: (conversationId) => createSessionForConversation(conversationId),
|
|
1871
|
+
isSessionRunning: (session) => session.state.state === 'running',
|
|
1872
|
+
writeTurnOnHeartbeat: (session) => session.state.state !== 'running' && session.state.state !== 'requires_action',
|
|
1873
|
+
writeSessionState: (session) => session.writeState(),
|
|
1874
|
+
writeSessionTurn: (session) => session.writeTurn(),
|
|
1875
|
+
releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
|
|
1876
|
+
persistResumeState,
|
|
1877
|
+
disposeRuntime: (session) => session.query.close(),
|
|
1878
|
+
interruptRuntime: (session) => session.query.interrupt(),
|
|
1879
|
+
port,
|
|
1880
|
+
publishRuntimeHeartbeat,
|
|
1881
|
+
onShutdown: async () => {
|
|
1882
|
+
detachControlNotifications();
|
|
1883
|
+
notifications.detach();
|
|
1884
|
+
approvalManager?.dispose();
|
|
1885
|
+
await port.clearAgentRuntime().catch(() => { });
|
|
1886
|
+
bridge.client.close();
|
|
2022
1887
|
},
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
|
|
2027
|
-
console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
|
|
1888
|
+
onShutdownComplete: () => {
|
|
1889
|
+
markLocalRuntimeStopped(runtimeId);
|
|
1890
|
+
lockHandle?.release();
|
|
2028
1891
|
},
|
|
2029
1892
|
});
|
|
1893
|
+
const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
|
|
1894
|
+
// Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
|
|
1895
|
+
const handleRuntimeControlSignal = createRuntimeSignalHandler({
|
|
1896
|
+
agentId,
|
|
1897
|
+
logPrefix: '[canon-host]',
|
|
1898
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1899
|
+
}, {
|
|
1900
|
+
sessions,
|
|
1901
|
+
port,
|
|
1902
|
+
isSessionRunning: (session) => session.state.state === 'running',
|
|
1903
|
+
interruptRuntime: (session) => session.query.interrupt(),
|
|
1904
|
+
resetSession: (session) => resetRuntimeSession(session),
|
|
1905
|
+
markSessionPendingInputsRejected,
|
|
1906
|
+
});
|
|
1907
|
+
// Control events arrive as bridge notifications — the bridge owns the ONE
|
|
1908
|
+
// authoritative /control consumer for this identity (plan §2); the host's
|
|
1909
|
+
// adaptive poller (active/idle cadence, jitter, per-session baselines) is
|
|
1910
|
+
// deleted with it.
|
|
1911
|
+
const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
|
|
1912
|
+
onSessionControl: applyRuntimeSessionControl,
|
|
1913
|
+
onControlSignal: handleRuntimeControlSignal,
|
|
1914
|
+
});
|
|
2030
1915
|
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
2031
1916
|
const savedSessionIds = new Map();
|
|
2032
|
-
const { getConversationMeta } = createConversationMetadataLoader({
|
|
2033
|
-
client,
|
|
2034
|
-
conversationCache,
|
|
2035
|
-
});
|
|
2036
1917
|
function resolveWorkspaceIdForBaseCwd(baseCwd) {
|
|
2037
1918
|
return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
|
|
2038
1919
|
}
|
|
2039
|
-
async function refreshKnownConversationIds(force = false) {
|
|
2040
|
-
if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
|
|
2041
|
-
return;
|
|
2042
|
-
}
|
|
2043
|
-
const conversations = await client.getConversations();
|
|
2044
|
-
knownConversationIds.clear();
|
|
2045
|
-
for (const conversation of conversations) {
|
|
2046
|
-
knownConversationIds.add(conversation.id);
|
|
2047
|
-
conversationCache.set(conversation.id, conversation);
|
|
2048
|
-
}
|
|
2049
|
-
lastKnownConversationRefreshAt = Date.now();
|
|
2050
|
-
}
|
|
2051
1920
|
async function canRequestCanonApproval(conversationId) {
|
|
2052
1921
|
if (!ownerId)
|
|
2053
1922
|
return false;
|
|
@@ -2055,80 +1924,9 @@ export async function main() {
|
|
|
2055
1924
|
.catch(() => conversationCache.get(conversationId) ?? null);
|
|
2056
1925
|
return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
|
|
2057
1926
|
}
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
return;
|
|
2062
|
-
const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
|
|
2063
|
-
const cached = conversationCache.get(payload.conversationId);
|
|
2064
|
-
const membershipChange = payload.membershipChange
|
|
2065
|
-
?? (cached ? diffCanonMemberIds(cached.memberIds, memberIds) : null);
|
|
2066
|
-
if (cached) {
|
|
2067
|
-
conversationCache.set(payload.conversationId, {
|
|
2068
|
-
...cached,
|
|
2069
|
-
memberIds,
|
|
2070
|
-
});
|
|
2071
|
-
}
|
|
2072
|
-
if (membershipChange) {
|
|
2073
|
-
pendingMembershipChanges.set(payload.conversationId, membershipChange);
|
|
2074
|
-
}
|
|
2075
|
-
if (!memberIds.includes(agentId)) {
|
|
2076
|
-
knownConversationIds.delete(payload.conversationId);
|
|
2077
|
-
conversationCache.delete(payload.conversationId);
|
|
2078
|
-
}
|
|
2079
|
-
}
|
|
2080
|
-
function getGroupContextMode(conversationId, conversation) {
|
|
2081
|
-
if (conversation?.type !== 'group')
|
|
2082
|
-
return undefined;
|
|
2083
|
-
if (pendingMembershipChanges.has(conversationId))
|
|
2084
|
-
return 'membership_change';
|
|
2085
|
-
if (!promptedGroupContextConversationIds.has(conversationId))
|
|
2086
|
-
return 'initial';
|
|
2087
|
-
return undefined;
|
|
2088
|
-
}
|
|
2089
|
-
function markGroupContextModeUsed(conversationId, mode) {
|
|
2090
|
-
if (!mode)
|
|
2091
|
-
return;
|
|
2092
|
-
promptedGroupContextConversationIds.add(conversationId);
|
|
2093
|
-
if (mode === 'membership_change') {
|
|
2094
|
-
pendingMembershipChanges.delete(conversationId);
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
async function loadHydratedInboundContext(input) {
|
|
2098
|
-
const [conversation, page] = await Promise.all([
|
|
2099
|
-
getConversationMeta(input.conversationId),
|
|
2100
|
-
input.hydratedPage
|
|
2101
|
-
? Promise.resolve(input.hydratedPage)
|
|
2102
|
-
: client.getMessagesPage(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => null),
|
|
2103
|
-
]);
|
|
2104
|
-
return buildHydratedInboundContext({
|
|
2105
|
-
agentId,
|
|
2106
|
-
conversationId: input.conversationId,
|
|
2107
|
-
conversation,
|
|
2108
|
-
page,
|
|
2109
|
-
activeSelfContextId: input.activeSelfContextId,
|
|
2110
|
-
selfContexts: input.selfContexts,
|
|
2111
|
-
provenance: input.provenance,
|
|
2112
|
-
message: input.message,
|
|
2113
|
-
senderName: input.senderName,
|
|
2114
|
-
isOwner: input.isOwner,
|
|
2115
|
-
ownerId,
|
|
2116
|
-
ownerName,
|
|
2117
|
-
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
2118
|
-
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
2119
|
-
});
|
|
2120
|
-
}
|
|
2121
|
-
function closeSession(conversationId, options = {}) {
|
|
2122
|
-
const session = sessions.get(conversationId);
|
|
2123
|
-
if (!session)
|
|
2124
|
-
return;
|
|
2125
|
-
const saveResume = options.saveResume !== false;
|
|
2126
|
-
session.closed = true;
|
|
2127
|
-
if (session.typingKeepaliveTimer) {
|
|
2128
|
-
clearInterval(session.typingKeepaliveTimer);
|
|
2129
|
-
session.typingKeepaliveTimer = null;
|
|
2130
|
-
}
|
|
2131
|
-
releaseConversationEnvironment(session.environment);
|
|
1927
|
+
/** Resume-state persistence on session close (claude-specific: SDK sessionId). */
|
|
1928
|
+
function persistResumeState(session, saveResume) {
|
|
1929
|
+
const conversationId = session.conversationId;
|
|
2132
1930
|
const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
|
|
2133
1931
|
if (saveResume && session.sdkSessionId) {
|
|
2134
1932
|
savedSessionIds.set(resumeKey, session.sdkSessionId);
|
|
@@ -2160,89 +1958,15 @@ export async function main() {
|
|
|
2160
1958
|
});
|
|
2161
1959
|
}
|
|
2162
1960
|
}
|
|
2163
|
-
session.clearStreaming().catch(() => { });
|
|
2164
|
-
if (session.idleResetTimer) {
|
|
2165
|
-
clearTimeout(session.idleResetTimer);
|
|
2166
|
-
session.idleResetTimer = null;
|
|
2167
|
-
}
|
|
2168
|
-
if (session.finalDeliveryTimer) {
|
|
2169
|
-
clearTimeout(session.finalDeliveryTimer);
|
|
2170
|
-
session.finalDeliveryTimer = null;
|
|
2171
|
-
}
|
|
2172
|
-
runtimeState.clearSessionState(conversationId).catch(() => { });
|
|
2173
|
-
runtimeState.clearTurnState(conversationId).catch(() => { });
|
|
2174
|
-
typingSignals.clear(conversationId).catch(() => { });
|
|
2175
|
-
typingSignals.dispose(conversationId);
|
|
2176
|
-
try {
|
|
2177
|
-
session.query.close();
|
|
2178
|
-
}
|
|
2179
|
-
catch { }
|
|
2180
|
-
sessions.delete(conversationId);
|
|
2181
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${options.reason ?? (saveResume ? 'suspended (sessionId saved for resume)' : 'cleared')}`);
|
|
2182
|
-
}
|
|
2183
|
-
async function resetRuntimeSession(session) {
|
|
2184
|
-
const conversationId = session.conversationId;
|
|
2185
|
-
const droppedInputs = session.pendingInputs.splice(0);
|
|
2186
|
-
await markSessionPendingInputsRejected(conversationId, droppedInputs);
|
|
2187
|
-
if (session.activeInput) {
|
|
2188
|
-
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2189
|
-
}
|
|
2190
|
-
session.pendingFinalText = null;
|
|
2191
|
-
session.pendingFinalDelivery = null;
|
|
2192
|
-
session.turnState = 'interrupted';
|
|
2193
|
-
session.clearStreaming().catch(() => { });
|
|
2194
|
-
typingSignals.clear(conversationId).catch(() => { });
|
|
2195
|
-
if (session.state.state === 'running') {
|
|
2196
|
-
await session.query.interrupt().catch(() => { });
|
|
2197
|
-
}
|
|
2198
|
-
closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
|
|
2199
|
-
}
|
|
2200
|
-
async function markSessionPendingInputsRejected(conversationId, inputs) {
|
|
2201
|
-
await Promise.all(inputs.map((input) => {
|
|
2202
|
-
if (!input.markAccepted || !input.sourceMessageId)
|
|
2203
|
-
return Promise.resolve();
|
|
2204
|
-
return client.updateMessageDisposition(conversationId, input.sourceMessageId, 'rejected').catch(() => { });
|
|
2205
|
-
}));
|
|
2206
1961
|
}
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
session.writeTurn();
|
|
2216
|
-
}
|
|
2217
|
-
function evictOldestIdle() {
|
|
2218
|
-
let oldest = null;
|
|
2219
|
-
for (const s of sessions.values()) {
|
|
2220
|
-
if (s.state.state === 'running')
|
|
2221
|
-
continue; // Don't evict running sessions
|
|
2222
|
-
if (!oldest || s.lastActivity < oldest.lastActivity)
|
|
2223
|
-
oldest = s;
|
|
2224
|
-
}
|
|
2225
|
-
if (oldest) {
|
|
2226
|
-
console.error(`[canon-host] [${oldest.conversationId.slice(0, 8)}] Evicting idle session`);
|
|
2227
|
-
closeSession(oldest.conversationId);
|
|
2228
|
-
}
|
|
2229
|
-
}
|
|
2230
|
-
async function getOrCreateSession(conversationId) {
|
|
2231
|
-
knownConversationIds.add(conversationId);
|
|
2232
|
-
const existing = sessions.get(conversationId);
|
|
2233
|
-
if (existing && !existing.closed) {
|
|
2234
|
-
existing.lastActivity = Date.now();
|
|
2235
|
-
return existing;
|
|
2236
|
-
}
|
|
2237
|
-
const pending = pendingSessionCreations.get(conversationId);
|
|
2238
|
-
if (pending)
|
|
2239
|
-
return pending;
|
|
2240
|
-
// Evict if at capacity
|
|
2241
|
-
if (sessions.size >= MAX_SESSIONS) {
|
|
2242
|
-
evictOldestIdle();
|
|
2243
|
-
}
|
|
2244
|
-
const creation = (async () => {
|
|
2245
|
-
const config = await loadSessionConfig(conversationId, agentId);
|
|
1962
|
+
/** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
|
|
1963
|
+
function createSessionForConversation(conversationId) {
|
|
1964
|
+
return (async () => {
|
|
1965
|
+
const config = await loadSessionConfig(port, conversationId);
|
|
1966
|
+
const sessionSettings = resolveEffectiveSessionSettings({
|
|
1967
|
+
profileDefaults: profileSessionDefaults,
|
|
1968
|
+
sessionConfig: config,
|
|
1969
|
+
});
|
|
2246
1970
|
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
2247
1971
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
2248
1972
|
const environment = prepareConversationEnvironment({
|
|
@@ -2259,7 +1983,7 @@ export async function main() {
|
|
|
2259
1983
|
executionMode: environment.mode,
|
|
2260
1984
|
});
|
|
2261
1985
|
const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
|
|
2262
|
-
const session = createSession(conversationId, environment, agentId,
|
|
1986
|
+
const session = createSession(conversationId, environment, agentId, port, typingSignals, (convoId, sdkSessionId) => {
|
|
2263
1987
|
// Called when session processing loop ends — save session ID for resume
|
|
2264
1988
|
const existing = sessions.get(convoId);
|
|
2265
1989
|
if (existing) {
|
|
@@ -2283,17 +2007,17 @@ export async function main() {
|
|
|
2283
2007
|
}
|
|
2284
2008
|
existing?.clearStreaming().catch(() => { });
|
|
2285
2009
|
typingSignals.clear(convoId).catch(() => { });
|
|
2286
|
-
typingSignals.dispose(convoId);
|
|
2287
2010
|
sessions.delete(convoId);
|
|
2288
|
-
|
|
2289
|
-
|
|
2011
|
+
port.clearSessionState({ conversationId: convoId }).catch(() => { });
|
|
2012
|
+
port.clearTurnState({ conversationId: convoId }).catch(() => { });
|
|
2290
2013
|
}, (descriptor) => {
|
|
2291
2014
|
runtimeDescriptor = descriptor;
|
|
2292
2015
|
void publishRuntimeHeartbeat();
|
|
2293
2016
|
}, {
|
|
2294
|
-
model:
|
|
2017
|
+
model: sessionSettings.model ?? runtimeModels[0]?.value,
|
|
2295
2018
|
permissionMode: config?.permissionMode ?? 'default',
|
|
2296
|
-
effort:
|
|
2019
|
+
effort: sessionSettings.effort,
|
|
2020
|
+
sessionSettings,
|
|
2297
2021
|
availableModels: runtimeModels,
|
|
2298
2022
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
2299
2023
|
runtimePresentation,
|
|
@@ -2303,7 +2027,6 @@ export async function main() {
|
|
|
2303
2027
|
ownerId,
|
|
2304
2028
|
}, resumeId);
|
|
2305
2029
|
sessions.set(conversationId, session);
|
|
2306
|
-
await controlPoller.baseline([conversationId]);
|
|
2307
2030
|
return session;
|
|
2308
2031
|
}
|
|
2309
2032
|
catch (error) {
|
|
@@ -2311,13 +2034,6 @@ export async function main() {
|
|
|
2311
2034
|
throw error;
|
|
2312
2035
|
}
|
|
2313
2036
|
})();
|
|
2314
|
-
pendingSessionCreations.set(conversationId, creation);
|
|
2315
|
-
try {
|
|
2316
|
-
return await creation;
|
|
2317
|
-
}
|
|
2318
|
-
finally {
|
|
2319
|
-
pendingSessionCreations.delete(conversationId);
|
|
2320
|
-
}
|
|
2321
2037
|
}
|
|
2322
2038
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2323
2039
|
const m = input.message;
|
|
@@ -2374,7 +2090,9 @@ export async function main() {
|
|
|
2374
2090
|
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2375
2091
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
|
|
2376
2092
|
await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
|
|
2377
|
-
await
|
|
2093
|
+
await port.sendMessage({
|
|
2094
|
+
conversationId: input.conversationId,
|
|
2095
|
+
text: `I couldn't start a coding session for this workspace: ${userMessage}`,
|
|
2378
2096
|
messageId: `claude-start-failed-${m.id}`,
|
|
2379
2097
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2380
2098
|
metadata: {
|
|
@@ -2424,7 +2142,12 @@ export async function main() {
|
|
|
2424
2142
|
baseCwd: workingDir,
|
|
2425
2143
|
})?.lastInboundMessageId;
|
|
2426
2144
|
const recovery = await collectMissedInboundMessages({
|
|
2427
|
-
fetchPage: (before) =>
|
|
2145
|
+
fetchPage: (before) => port.getMessages({
|
|
2146
|
+
conversationId: conversation.id,
|
|
2147
|
+
limit: STARTUP_RECOVERY_PAGE_SIZE,
|
|
2148
|
+
...(before ? { before } : {}),
|
|
2149
|
+
includeBehavior: true,
|
|
2150
|
+
}),
|
|
2428
2151
|
cursor,
|
|
2429
2152
|
agentId,
|
|
2430
2153
|
});
|
|
@@ -2447,188 +2170,208 @@ export async function main() {
|
|
|
2447
2170
|
catch (error) {
|
|
2448
2171
|
console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
|
|
2449
2172
|
}
|
|
2450
|
-
// ──
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2173
|
+
// ── Inbound Canon events (bridge notifications) ──
|
|
2174
|
+
// The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
|
|
2175
|
+
// HITL control-reply messages (approval_reply / plan_approval_reply) are
|
|
2176
|
+
// STRIPPED from onMessage bridge-side — unmatched ones arrive as
|
|
2177
|
+
// onControlReply and feed the plan-approval waiters below.
|
|
2178
|
+
const handleInboundMessage = (payload) => {
|
|
2179
|
+
const m = payload.message;
|
|
2180
|
+
if (m.senderId === agentId)
|
|
2181
|
+
return;
|
|
2182
|
+
const convoId = payload.conversationId;
|
|
2183
|
+
knownConversationIds.add(convoId);
|
|
2184
|
+
const sender = m.senderName || m.senderId;
|
|
2185
|
+
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2186
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2187
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
void (async () => {
|
|
2191
|
+
let materialized = [];
|
|
2192
|
+
if (m.id) {
|
|
2193
|
+
try {
|
|
2194
|
+
materialized = await materializeMessageMedia({
|
|
2195
|
+
id: m.id,
|
|
2196
|
+
attachments: m.attachments ?? [],
|
|
2197
|
+
}, { agentId, conversationId: convoId });
|
|
2474
2198
|
}
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
const existingSession = sessions.get(convoId);
|
|
2478
|
-
const pendingReply = existingSession?.pendingReply;
|
|
2479
|
-
const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2480
|
-
const replyRequestId = m.metadata.planId;
|
|
2481
|
-
if (!pendingReply) {
|
|
2482
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
|
|
2483
|
-
return;
|
|
2484
|
-
}
|
|
2485
|
-
if (!isOwnerReply) {
|
|
2486
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
|
|
2487
|
-
return;
|
|
2488
|
-
}
|
|
2489
|
-
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2490
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2491
|
-
return;
|
|
2492
|
-
}
|
|
2493
|
-
if (existingSession?.pendingReply) {
|
|
2494
|
-
const { resolve } = existingSession.pendingReply;
|
|
2495
|
-
existingSession.pendingReply = null;
|
|
2496
|
-
existingSession.lastActivity = Date.now();
|
|
2497
|
-
resolve(m.metadata);
|
|
2498
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2499
|
-
return;
|
|
2500
|
-
}
|
|
2199
|
+
catch (error) {
|
|
2200
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
2501
2201
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2202
|
+
}
|
|
2203
|
+
const content = renderInboundContent(m, materialized);
|
|
2204
|
+
const hydrated = await loadHydratedInboundContext({
|
|
2205
|
+
conversationId: convoId,
|
|
2206
|
+
message: m,
|
|
2207
|
+
senderName: sender,
|
|
2208
|
+
isOwner,
|
|
2209
|
+
activeSelfContextId: payload.activeSelfContextId,
|
|
2210
|
+
selfContexts: payload.selfContexts,
|
|
2211
|
+
provenance: payload.provenance,
|
|
2212
|
+
});
|
|
2213
|
+
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2214
|
+
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
2215
|
+
const selfContexts = hydrated.selfContexts;
|
|
2216
|
+
const replyMedia = await materializePromptReplyContext({
|
|
2217
|
+
replyContext: hydrated.replyContext,
|
|
2218
|
+
agentId,
|
|
2219
|
+
conversationId: convoId,
|
|
2220
|
+
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
2221
|
+
});
|
|
2222
|
+
const replyContext = replyMedia.replyContext;
|
|
2223
|
+
const participantContext = hydrated.participantContext;
|
|
2224
|
+
const autoReply = payload.turnDispatch?.kind === 'run_turn'
|
|
2225
|
+
? {
|
|
2226
|
+
allow: true,
|
|
2227
|
+
reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
|
|
2507
2228
|
}
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
}
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2229
|
+
: decideAutoReply(participantContext, behavior);
|
|
2230
|
+
if (!autoReply.allow) {
|
|
2231
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
markGroupContextModeUsed(convoId, participantContext.groupContextMode);
|
|
2235
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
2236
|
+
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
2237
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
2238
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
2239
|
+
let session;
|
|
2240
|
+
try {
|
|
2241
|
+
session = await getOrCreateSession(convoId);
|
|
2242
|
+
}
|
|
2243
|
+
catch (error) {
|
|
2244
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2245
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2246
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2247
|
+
await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
|
|
2248
|
+
await port.sendMessage({
|
|
2249
|
+
conversationId: convoId,
|
|
2250
|
+
text: `I couldn't start a coding session for this workspace: ${userMessage}`,
|
|
2251
|
+
messageId: `claude-start-failed-${m.id}`,
|
|
2252
|
+
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2253
|
+
metadata: {
|
|
2254
|
+
turnSemantics: 'turn_complete',
|
|
2255
|
+
replyBehavior: 'suppress_auto_reply',
|
|
2256
|
+
},
|
|
2257
|
+
}).catch(() => { });
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
session.activeSelfContextId = activeSelfContextId;
|
|
2261
|
+
const promptText = buildCanonPrompt({
|
|
2262
|
+
content,
|
|
2263
|
+
conversationId: convoId,
|
|
2264
|
+
participantContext,
|
|
2265
|
+
behavior,
|
|
2266
|
+
selfContexts,
|
|
2267
|
+
activeSelfContextId,
|
|
2268
|
+
provenance: hydrated.provenance,
|
|
2269
|
+
replyContext,
|
|
2270
|
+
message: m,
|
|
2271
|
+
});
|
|
2272
|
+
const messageContent = await buildCanonUserContent({
|
|
2273
|
+
promptText,
|
|
2274
|
+
materialized: [...replyMedia.materialized, ...materialized],
|
|
2275
|
+
});
|
|
2276
|
+
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
2277
|
+
session.enqueueInbound({
|
|
2278
|
+
type: 'user',
|
|
2279
|
+
message: {
|
|
2280
|
+
role: 'user',
|
|
2281
|
+
content: messageContent,
|
|
2282
|
+
},
|
|
2283
|
+
parent_tool_use_id: null,
|
|
2284
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2285
|
+
if (m.id) {
|
|
2286
|
+
saveRuntimeSessionState(runtimeId, {
|
|
2287
|
+
conversationId: convoId,
|
|
2288
|
+
baseCwd: workingDir,
|
|
2289
|
+
lastInboundMessageId: m.id,
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
})();
|
|
2293
|
+
};
|
|
2294
|
+
/**
|
|
2295
|
+
* Stripped HITL control replies that matched no bridge record: the plan
|
|
2296
|
+
* approval flow (host-raised via a plain sendMessage) resolves its
|
|
2297
|
+
* metadata-reply waiter here; anything else is fed to the approval
|
|
2298
|
+
* manager as a late/second-chance consume.
|
|
2299
|
+
*/
|
|
2300
|
+
const handleControlReply = (convoId, m) => {
|
|
2301
|
+
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2302
|
+
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2303
|
+
senderId: m.senderId,
|
|
2304
|
+
metadata: m.metadata,
|
|
2305
|
+
}) ?? false;
|
|
2306
|
+
if (m.metadata?.type === 'approval_reply') {
|
|
2307
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (consumed) {
|
|
2311
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
if (m.metadata?.type === 'plan_approval_reply') {
|
|
2316
|
+
const existingSession = sessions.get(convoId);
|
|
2317
|
+
const pendingReply = existingSession?.pendingReply;
|
|
2318
|
+
const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2319
|
+
const replyRequestId = m.metadata.planId;
|
|
2320
|
+
if (!pendingReply) {
|
|
2321
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
if (!isOwnerReply) {
|
|
2325
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2329
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2330
|
+
return;
|
|
2331
|
+
}
|
|
2332
|
+
if (existingSession?.pendingReply) {
|
|
2333
|
+
const { resolve } = existingSession.pendingReply;
|
|
2334
|
+
existingSession.pendingReply = null;
|
|
2335
|
+
existingSession.lastActivity = Date.now();
|
|
2336
|
+
resolve(m.metadata);
|
|
2337
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
};
|
|
2341
|
+
notifications.attach({
|
|
2342
|
+
onMessage: (params) => {
|
|
2343
|
+
handleInboundMessage(params);
|
|
2344
|
+
},
|
|
2345
|
+
onMessageDeleted: (params) => {
|
|
2346
|
+
const payload = params;
|
|
2347
|
+
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
2348
|
+
},
|
|
2349
|
+
onConversationUpdated: (params) => {
|
|
2350
|
+
handleConversationUpdated(params);
|
|
2351
|
+
},
|
|
2352
|
+
onControlReply: (params) => {
|
|
2353
|
+
const payload = params;
|
|
2354
|
+
handleControlReply(payload.conversationId, payload.message);
|
|
2355
|
+
},
|
|
2356
|
+
ready: () => {
|
|
2357
|
+
streamConnected = true;
|
|
2358
|
+
void publishRuntimeHeartbeat();
|
|
2359
|
+
console.error('[canon-host] Bridge ready');
|
|
2360
|
+
},
|
|
2361
|
+
connectionState: (params) => {
|
|
2362
|
+
const { upstream } = params;
|
|
2363
|
+
if (upstream === 'connected') {
|
|
2617
2364
|
streamConnected = true;
|
|
2618
2365
|
void publishRuntimeHeartbeat();
|
|
2619
|
-
console.error('[canon-host]
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2366
|
+
console.error('[canon-host] Canon upstream connected');
|
|
2367
|
+
}
|
|
2368
|
+
else {
|
|
2622
2369
|
streamConnected = false;
|
|
2623
|
-
|
|
2624
|
-
console.error(
|
|
2625
|
-
}
|
|
2626
|
-
onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
|
|
2370
|
+
port.clearAgentRuntime().catch(() => { });
|
|
2371
|
+
console.error(`[canon-host] Canon upstream ${upstream}`);
|
|
2372
|
+
}
|
|
2627
2373
|
},
|
|
2628
2374
|
});
|
|
2629
|
-
stream.start().catch((err) => {
|
|
2630
|
-
console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
|
|
2631
|
-
});
|
|
2632
2375
|
// ── Control signal watcher ──
|
|
2633
2376
|
// Session control signals (model, mode, effort). A missing/closed session
|
|
2634
2377
|
// leaves the node in place; lastSeen still advances, so a stale control is
|
|
@@ -2685,94 +2428,54 @@ export async function main() {
|
|
|
2685
2428
|
else if (ctrl.effort && !nextEffort) {
|
|
2686
2429
|
session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
|
|
2687
2430
|
}
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
async function handleRuntimeControlSignal(event) {
|
|
2694
|
-
const convoId = event.conversationId;
|
|
2695
|
-
const session = sessions.get(convoId);
|
|
2696
|
-
if (!session || session.closed)
|
|
2697
|
-
return { consume: false };
|
|
2698
|
-
if (event.type === 'new_session') {
|
|
2699
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2700
|
-
await resetRuntimeSession(session);
|
|
2701
|
-
return;
|
|
2702
|
-
}
|
|
2703
|
-
const isActive = session.state.state === 'running';
|
|
2704
|
-
if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2705
|
-
return; // Nothing to interrupt — consume the stale signal
|
|
2706
|
-
}
|
|
2707
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2708
|
-
if (event.type === 'stop_and_drop') {
|
|
2709
|
-
const droppedInputs = session.pendingInputs.splice(0);
|
|
2710
|
-
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2711
|
-
}
|
|
2712
|
-
if (session.activeInput) {
|
|
2713
|
-
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2714
|
-
}
|
|
2715
|
-
session.pendingFinalText = null;
|
|
2716
|
-
session.turnState = 'interrupted';
|
|
2717
|
-
session.clearStreaming().catch(() => { });
|
|
2718
|
-
typingSignals.clear(convoId).catch(() => { });
|
|
2719
|
-
runtimeState.writeTurnState(convoId, {
|
|
2720
|
-
turnId: session.currentTurnId,
|
|
2721
|
-
state: 'interrupted',
|
|
2722
|
-
queueDepth: session.pendingInputs.length,
|
|
2723
|
-
currentSpeakerId: agentId,
|
|
2724
|
-
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
2725
|
-
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
2726
|
-
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2727
|
-
completedAt: { '.sv': 'timestamp' },
|
|
2728
|
-
}).catch(() => { });
|
|
2729
|
-
if (isActive) {
|
|
2730
|
-
await session.query.interrupt();
|
|
2731
|
-
}
|
|
2732
|
-
}
|
|
2733
|
-
controlPoller.start();
|
|
2734
|
-
// ── Heartbeat (per-session) ──
|
|
2735
|
-
const heartbeat = setInterval(() => {
|
|
2736
|
-
for (const session of sessions.values()) {
|
|
2737
|
-
session.writeState();
|
|
2738
|
-
if (session.state.state !== 'running' && session.state.state !== 'requires_action') {
|
|
2739
|
-
session.writeTurn();
|
|
2431
|
+
const runtimeControlValues = ctrl.runtimeControlValues ?? {};
|
|
2432
|
+
if ('thinking' in runtimeControlValues) {
|
|
2433
|
+
const nextThinking = parseClaudeThinkingMode(runtimeControlValues.thinking);
|
|
2434
|
+
if (!nextThinking) {
|
|
2435
|
+
session.setRuntimeControlError('thinking', String(runtimeControlValues.thinking), new Error('Unsupported Claude thinking mode.'));
|
|
2740
2436
|
}
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2437
|
+
else if (nextThinking !== session.state.runtimeControlValues?.thinking) {
|
|
2438
|
+
try {
|
|
2439
|
+
await session.query.applyFlagSettings({ alwaysThinkingEnabled: claudeThinkingToFlagValue(nextThinking) });
|
|
2440
|
+
session.state.runtimeControlValues = {
|
|
2441
|
+
...(session.state.runtimeControlValues ?? {}),
|
|
2442
|
+
thinking: nextThinking,
|
|
2443
|
+
};
|
|
2444
|
+
session.clearRuntimeControlError('thinking');
|
|
2445
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Thinking → ${nextThinking}`);
|
|
2446
|
+
}
|
|
2447
|
+
catch (err) {
|
|
2448
|
+
session.setRuntimeControlError('thinking', nextThinking, err);
|
|
2449
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set thinking:`, err);
|
|
2450
|
+
}
|
|
2754
2451
|
}
|
|
2755
2452
|
}
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2453
|
+
if ('ultracode' in runtimeControlValues) {
|
|
2454
|
+
const nextUltracode = parseClaudeUltracodeMode(runtimeControlValues.ultracode);
|
|
2455
|
+
if (!nextUltracode) {
|
|
2456
|
+
session.setRuntimeControlError('ultracode', String(runtimeControlValues.ultracode), new Error('Unsupported Claude ultracode mode.'));
|
|
2457
|
+
}
|
|
2458
|
+
else if (nextUltracode !== session.state.runtimeControlValues?.ultracode) {
|
|
2459
|
+
try {
|
|
2460
|
+
await session.query.applyFlagSettings({ ultracode: nextUltracode === 'on' });
|
|
2461
|
+
session.state.runtimeControlValues = {
|
|
2462
|
+
...(session.state.runtimeControlValues ?? {}),
|
|
2463
|
+
ultracode: nextUltracode,
|
|
2464
|
+
};
|
|
2465
|
+
session.clearRuntimeControlError('ultracode');
|
|
2466
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ultracode → ${nextUltracode}`);
|
|
2467
|
+
}
|
|
2468
|
+
catch (err) {
|
|
2469
|
+
session.setRuntimeControlError('ultracode', nextUltracode, err);
|
|
2470
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set ultracode:`, err);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2768
2473
|
}
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
process.on('SIGTERM', shutdown);
|
|
2775
|
-
process.on('SIGHUP', shutdown);
|
|
2474
|
+
session.writeState();
|
|
2475
|
+
}
|
|
2476
|
+
// ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
|
|
2477
|
+
lifecycle.startTimers();
|
|
2478
|
+
lifecycle.installSignalHandlers();
|
|
2776
2479
|
console.error('[canon-host] Ready — sessions created on demand');
|
|
2777
2480
|
// Keep alive
|
|
2778
2481
|
await new Promise(() => { });
|
|
@@ -2781,7 +2484,7 @@ export async function main() {
|
|
|
2781
2484
|
runCli(import.meta.url, main, (err) => {
|
|
2782
2485
|
const message = err instanceof Error ? err.message : String(err);
|
|
2783
2486
|
console.error(`[canon-host] ${message}`);
|
|
2784
|
-
|
|
2487
|
+
activeLockHandle?.release();
|
|
2785
2488
|
process.exit(1);
|
|
2786
2489
|
}, {
|
|
2787
2490
|
name: 'canon-claude',
|