@canonmsg/claude-code-plugin 0.19.6 → 0.20.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 +456 -917
- package/dist/mcp-args.d.ts +1 -1
- package/dist/register.js +73 -25
- package/dist/server.js +23 -11
- package/dist/session-state.d.ts +2 -2
- 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,12 +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 { 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';
|
|
@@ -73,6 +75,11 @@ Keep this terminal open while you want Canon to reach the agent. Closing it,
|
|
|
73
75
|
logging out, rebooting, or sleeping long enough to stop the process takes the
|
|
74
76
|
local agent offline until you revive it. Docs:
|
|
75
77
|
https://canonmail.com/agents/integrations`;
|
|
78
|
+
// This host process resolves and locks exactly one agent profile. The lock
|
|
79
|
+
// handle returned by resolveCanonAgent is held here so the top-level runCli
|
|
80
|
+
// error handler (outside main's scope) can release it on a failed start —
|
|
81
|
+
// there is no core module-global profile-lock to fall back on.
|
|
82
|
+
let activeLockHandle = null;
|
|
76
83
|
let workingDir = process.cwd();
|
|
77
84
|
let workspaceOptions = [];
|
|
78
85
|
let workspaceRoots = [];
|
|
@@ -300,15 +307,15 @@ function toModelOptions(models) {
|
|
|
300
307
|
label: model.displayName,
|
|
301
308
|
}));
|
|
302
309
|
}
|
|
303
|
-
async function publishAgentRuntime(
|
|
304
|
-
await publishHostAgentRuntime(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const config = await loadHostSessionConfig({
|
|
308
|
-
conversationId,
|
|
309
|
-
agentId,
|
|
310
|
-
extraStringFields: ['permissionMode', 'effort'],
|
|
310
|
+
async function publishAgentRuntime(port, runtime) {
|
|
311
|
+
await port.publishHostAgentRuntime({
|
|
312
|
+
clientType: 'claude-code',
|
|
313
|
+
runtime: runtime,
|
|
311
314
|
});
|
|
315
|
+
}
|
|
316
|
+
async function loadSessionConfig(port, conversationId) {
|
|
317
|
+
const raw = await port.getSessionConfig({ conversationId });
|
|
318
|
+
const config = readHostSessionConfig(raw, ['permissionMode', 'effort']);
|
|
312
319
|
return {
|
|
313
320
|
...config,
|
|
314
321
|
permissionMode: config?.permissionMode
|
|
@@ -423,9 +430,6 @@ async function refreshClaudeRuntimeMetadata(input) {
|
|
|
423
430
|
});
|
|
424
431
|
return runtimeMetadataRefresh;
|
|
425
432
|
}
|
|
426
|
-
function sleep(ms) {
|
|
427
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
428
|
-
}
|
|
429
433
|
function mapClaudeQuestions(value) {
|
|
430
434
|
if (!Array.isArray(value))
|
|
431
435
|
return undefined;
|
|
@@ -656,9 +660,6 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
|
656
660
|
const STREAMING_THROTTLE_MS = 250;
|
|
657
661
|
const HEARTBEAT_MS = 30_000;
|
|
658
662
|
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
663
|
const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
663
664
|
const MAX_FINAL_DELIVERY_RETRIES = 1;
|
|
664
665
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
@@ -727,7 +728,7 @@ async function buildCanonUserContent(input) {
|
|
|
727
728
|
];
|
|
728
729
|
}
|
|
729
730
|
// ── Session factory ─────────────────────────────────────────────────
|
|
730
|
-
function createSession(conversationId, environment, agentId,
|
|
731
|
+
function createSession(conversationId, environment, agentId, port, typingSignals, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
731
732
|
const { cwd } = environment;
|
|
732
733
|
const cardResponseUserId = config.ownerId ?? undefined;
|
|
733
734
|
const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
|
|
@@ -787,48 +788,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
787
788
|
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
788
789
|
let session;
|
|
789
790
|
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
|
-
}
|
|
791
|
+
// ── HITL waiters (generic skeleton from @canonmsg/agent-host) ──
|
|
792
|
+
const hitlWaiters = createHitlWaiters({
|
|
793
|
+
conversationId,
|
|
794
|
+
replyTimeoutMs: REPLY_TIMEOUT_MS,
|
|
795
|
+
}, {
|
|
796
|
+
session: () => session,
|
|
797
|
+
port,
|
|
798
|
+
});
|
|
799
|
+
const { waitForReply, waitForRuntimeInputResponse } = hitlWaiters;
|
|
832
800
|
function buildArtifactMediaMetadata() {
|
|
833
801
|
return {
|
|
834
802
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
@@ -864,25 +832,27 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
864
832
|
const questionId = randomUUID();
|
|
865
833
|
const questions = mapClaudeQuestions(input.questions);
|
|
866
834
|
const expiresAt = Date.now() + REPLY_TIMEOUT_MS;
|
|
867
|
-
await
|
|
835
|
+
await port.requestInput({
|
|
868
836
|
conversationId,
|
|
869
837
|
inputId: questionId,
|
|
870
838
|
kind: 'clarify',
|
|
871
|
-
expiresAt,
|
|
839
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
872
840
|
title: 'Claude needs input',
|
|
873
841
|
prompt: questions?.length
|
|
874
842
|
? 'Claude needs your input to continue.'
|
|
875
843
|
: 'Claude needs input to continue.',
|
|
876
|
-
...(questions
|
|
844
|
+
...(questions
|
|
845
|
+
? { questions: questions }
|
|
846
|
+
: { questions: [] }),
|
|
877
847
|
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
878
848
|
...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
|
|
879
849
|
native: {
|
|
880
850
|
runtime: 'claude-code',
|
|
881
851
|
method: 'AskUserQuestion',
|
|
882
852
|
requestId: questionId,
|
|
883
|
-
turnId: session.currentTurnId
|
|
853
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
884
854
|
},
|
|
885
|
-
turnId: session.currentTurnId
|
|
855
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
886
856
|
});
|
|
887
857
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
888
858
|
const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
|
|
@@ -937,7 +907,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
937
907
|
...(cardResponseUser ?? {}),
|
|
938
908
|
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
939
909
|
});
|
|
940
|
-
await
|
|
910
|
+
await port.sendMessage({
|
|
911
|
+
conversationId,
|
|
912
|
+
text: planApproval.text,
|
|
941
913
|
messageId: `claude-plan-${planId}`,
|
|
942
914
|
...(session.activeSelfContextId
|
|
943
915
|
? { selfContextId: session.activeSelfContextId }
|
|
@@ -1090,155 +1062,93 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1090
1062
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1091
1063
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1092
1064
|
};
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
text: snapshot.text,
|
|
1105
|
-
status: snapshot.status,
|
|
1106
|
-
messageId: turnId,
|
|
1107
|
-
turnId,
|
|
1108
|
-
blocks,
|
|
1109
|
-
});
|
|
1110
|
-
},
|
|
1111
|
-
clearSnapshot: () => {
|
|
1112
|
-
session.streamingText = '';
|
|
1113
|
-
return runtimeState.clearStreaming(conversationId);
|
|
1065
|
+
// ── Per-session RTDB writers (generic skeleton from @canonmsg/agent-host) ──
|
|
1066
|
+
const writers = createRuntimeWriters({
|
|
1067
|
+
conversationId,
|
|
1068
|
+
agentId,
|
|
1069
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1070
|
+
fallbackTurnId: `claude-${conversationId}`,
|
|
1071
|
+
streamingThrottleMs: STREAMING_THROTTLE_MS,
|
|
1072
|
+
requestedControls: {
|
|
1073
|
+
model: config.model,
|
|
1074
|
+
permissionMode: config.permissionMode,
|
|
1075
|
+
effort: config.effort,
|
|
1114
1076
|
},
|
|
1077
|
+
}, {
|
|
1078
|
+
session: () => session,
|
|
1079
|
+
port,
|
|
1080
|
+
buildSessionStateExtras: (writerSession) => ({
|
|
1081
|
+
cwd,
|
|
1082
|
+
executionMode: writerSession.environment.mode,
|
|
1083
|
+
...(writerSession.environment.branch ? { executionBranch: writerSession.environment.branch } : {}),
|
|
1084
|
+
...(writerSession.environment.worktreePath ? { worktreePath: writerSession.environment.worktreePath } : {}),
|
|
1085
|
+
...(resolveExecutionFallbackReason(writerSession.environment)
|
|
1086
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(writerSession.environment) ?? undefined }
|
|
1087
|
+
: {}),
|
|
1088
|
+
}),
|
|
1089
|
+
formatControlError: formatClaudeControlError,
|
|
1115
1090
|
});
|
|
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
|
-
}
|
|
1091
|
+
const { streamingOutput, flushStreaming, onStreamDelta, clearStreaming, getFinalTurnTrail, writeState, writeTurn, setRuntimeControlError, clearRuntimeControlError, refreshVisibleWorkSignal, startVisibleWorkSignal, stopVisibleWorkSignal, } = writers;
|
|
1127
1092
|
function isActiveTurnInterrupted() {
|
|
1128
1093
|
const turnKey = session.activeInput?.turnKey;
|
|
1129
1094
|
return Boolean(turnKey && session.interruptedTurnKeys.has(turnKey));
|
|
1130
1095
|
}
|
|
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({
|
|
1096
|
+
// ── Turn queue + final delivery (generic skeleton from @canonmsg/agent-host) ──
|
|
1097
|
+
const turnQueue = createTurnQueue({ conversationId }, {
|
|
1098
|
+
session: () => session,
|
|
1099
|
+
isSessionRunning: (queueSession) => queueSession.state.state === 'running',
|
|
1100
|
+
markSessionRunning: (queueSession) => { queueSession.state.state = 'running'; },
|
|
1101
|
+
prepareInput: (input) => prepareArtifactsForInput(input),
|
|
1102
|
+
sendInput: (input) => sendInput(input),
|
|
1103
|
+
interruptRuntime: (queueSession) => queueSession.query.interrupt(),
|
|
1104
|
+
port,
|
|
1105
|
+
writers: {
|
|
1106
|
+
writeTurn,
|
|
1107
|
+
stopVisibleWorkSignal,
|
|
1108
|
+
clearStreaming,
|
|
1109
|
+
},
|
|
1110
|
+
});
|
|
1111
|
+
const { startCanonInput, drainPendingInput, markQueuedMessageAccepted } = turnQueue;
|
|
1112
|
+
const finalDelivery = createFinalDelivery({
|
|
1113
|
+
conversationId,
|
|
1114
|
+
logPrefix: '[canon-host]',
|
|
1115
|
+
delivery: { mode: 'retry', retryMs: FINAL_DELIVERY_RETRY_MS, maxRetries: MAX_FINAL_DELIVERY_RETRIES },
|
|
1116
|
+
}, {
|
|
1117
|
+
session: () => session,
|
|
1118
|
+
port,
|
|
1119
|
+
typingSignals,
|
|
1120
|
+
writers: {
|
|
1121
|
+
writeState,
|
|
1122
|
+
writeTurn,
|
|
1123
|
+
stopVisibleWorkSignal,
|
|
1124
|
+
clearStreaming,
|
|
1125
|
+
getFinalTurnTrail,
|
|
1126
|
+
replaceStreamingSnapshot: (text, status) => streamingOutput.replaceSnapshot(text, status),
|
|
1127
|
+
},
|
|
1128
|
+
resetCompletedTurnState: (deliverySession) => resetClaudeCompletedTurnState(deliverySession),
|
|
1129
|
+
shouldDeliverFinal: (turn, deliverySession) => shouldDeliverClaudeFinal({
|
|
1195
1130
|
turn,
|
|
1196
|
-
finalizedTurnKeys:
|
|
1197
|
-
interruptedTurnKeys:
|
|
1198
|
-
})
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1131
|
+
finalizedTurnKeys: deliverySession.finalizedTurnKeys,
|
|
1132
|
+
interruptedTurnKeys: deliverySession.interruptedTurnKeys,
|
|
1133
|
+
}),
|
|
1134
|
+
buildFinalMessageId: (turnKey) => buildClaudeFinalMessageId({ agentId, conversationId, turnKey }),
|
|
1135
|
+
drainPendingInput: () => drainPendingInput(),
|
|
1136
|
+
});
|
|
1137
|
+
const { clearIdleResetTimer, clearFinalDeliveryTimer, resetTurnToIdle, scheduleFinalHandoffReset, markPendingFinalDelivery, deliverFinalReply, scheduleFinalDeliveryRetry, } = finalDelivery;
|
|
1138
|
+
async function sendTurnArtifactFile(file) {
|
|
1139
|
+
// Byte transfer is bridge-owned (§B8): hand the bridge a path, it streams
|
|
1140
|
+
// the upload; then send the permanent message with the attachment ref.
|
|
1141
|
+
const uploaded = await port.uploadMedia({
|
|
1142
|
+
path: file.path,
|
|
1143
|
+
mime: inferUploadMimeType(file.path),
|
|
1144
|
+
fileName: file.fileName,
|
|
1206
1145
|
conversationId,
|
|
1207
|
-
turnKey: deliverTurn.turnKey,
|
|
1208
1146
|
});
|
|
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, '', {
|
|
1147
|
+
return port.sendMessage({
|
|
1148
|
+
conversationId,
|
|
1149
|
+
text: '',
|
|
1150
|
+
contentType: uploaded.attachment.kind,
|
|
1151
|
+
attachments: [uploaded.attachment],
|
|
1242
1152
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1243
1153
|
metadata: buildArtifactMediaMetadata(),
|
|
1244
1154
|
});
|
|
@@ -1275,138 +1185,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1275
1185
|
await routeWorkspaceGeneratedArtifacts(input);
|
|
1276
1186
|
}
|
|
1277
1187
|
}
|
|
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
1188
|
async function prepareArtifactsForInput(input) {
|
|
1411
1189
|
if (input.kind !== 'canon')
|
|
1412
1190
|
return;
|
|
@@ -1422,25 +1200,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1422
1200
|
return;
|
|
1423
1201
|
}
|
|
1424
1202
|
}
|
|
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
1203
|
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
1442
|
-
|
|
1443
|
-
const input = createClaudeInputEnvelope({
|
|
1204
|
+
turnQueue.enqueueCanonInput(createClaudeInputEnvelope({
|
|
1444
1205
|
kind: 'canon',
|
|
1445
1206
|
msg,
|
|
1446
1207
|
intent,
|
|
@@ -1448,32 +1209,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1448
1209
|
markAccepted,
|
|
1449
1210
|
isOwnerTurn,
|
|
1450
1211
|
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);
|
|
1212
|
+
}));
|
|
1477
1213
|
};
|
|
1478
1214
|
writeTurn();
|
|
1479
1215
|
// ── Process SDK output messages ──
|
|
@@ -1752,41 +1488,56 @@ export async function main() {
|
|
|
1752
1488
|
for (const warning of workspaceDiscovery.warnings) {
|
|
1753
1489
|
console.error(`[canon-host] ${warning}`);
|
|
1754
1490
|
}
|
|
1755
|
-
const {
|
|
1491
|
+
const { agentName: profileAgentName, profile, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
|
|
1492
|
+
activeLockHandle = lockHandle ?? null;
|
|
1756
1493
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
1757
|
-
// ── Canon
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1494
|
+
// ── Canon via the Bridge (bridge-execution-plan Phase 6) ──
|
|
1495
|
+
// One connectBridge per host process: the daemon owns the SSE stream,
|
|
1496
|
+
// RTDB auth, control polling, dedup, and reconnect. Notification handlers
|
|
1497
|
+
// must exist BEFORE the hello response lands (replayed frames dispatch
|
|
1498
|
+
// synchronously), so a buffered source subscribes inside `configure` and
|
|
1499
|
+
// the real handlers attach after the session machinery is built.
|
|
1500
|
+
const notifications = createBufferedNotificationSource([
|
|
1501
|
+
'ready',
|
|
1502
|
+
'connectionState',
|
|
1503
|
+
'onMessage',
|
|
1504
|
+
'onMessageDeleted',
|
|
1505
|
+
'onConversationUpdated',
|
|
1506
|
+
'onSessionControl',
|
|
1507
|
+
'onControlSignal',
|
|
1508
|
+
'onControlReply',
|
|
1509
|
+
]);
|
|
1510
|
+
const bridge = await connectBridge({
|
|
1511
|
+
...(profile ? { profile } : {}),
|
|
1512
|
+
mode: 'auto',
|
|
1513
|
+
...(resolvePackagedBridgeBin() ? { binPath: resolvePackagedBridgeBin() } : {}),
|
|
1514
|
+
hello: {
|
|
1515
|
+
clientType: 'claude-code',
|
|
1516
|
+
wantFamilies: ['messages', 'runtime_turn'],
|
|
1517
|
+
},
|
|
1518
|
+
configure: (bridgeClient) => notifications.connect(bridgeClient),
|
|
1519
|
+
});
|
|
1520
|
+
// The BridgeClient's zod-derived result shapes mirror the core wire types
|
|
1521
|
+
// structurally; the port pins the strong types the host machinery uses.
|
|
1522
|
+
const port = bridge.client;
|
|
1523
|
+
bridge.client.onProtocolError((error) => {
|
|
1524
|
+
console.error(`[canon-host] Bridge protocol error: ${error.message}`);
|
|
1764
1525
|
});
|
|
1526
|
+
const typingSignals = createPortTypingSignals(port);
|
|
1765
1527
|
async function markQueuedMessageAcceptedForConversation(conversationId, sourceMessageId, markAccepted) {
|
|
1766
1528
|
if (!markAccepted || !sourceMessageId)
|
|
1767
1529
|
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}`);
|
|
1530
|
+
await port.setDisposition({
|
|
1531
|
+
conversationId,
|
|
1532
|
+
messageId: sourceMessageId,
|
|
1533
|
+
inboundDisposition: 'accepted_now',
|
|
1534
|
+
}).catch(() => { });
|
|
1789
1535
|
}
|
|
1536
|
+
const agentId = bridge.hello.agentId;
|
|
1537
|
+
const ownerId = bridge.hello.agentContext.ownerId ?? null;
|
|
1538
|
+
const ownerName = bridge.hello.agentContext.ownerName ?? null;
|
|
1539
|
+
console.error(`[canon-host] Connected as ${bridge.hello.agentName || agentId} `
|
|
1540
|
+
+ `(canon-bridge${bridge.spawnedDaemon ? ' spawned' : ''} at ${bridge.socketPath})`);
|
|
1790
1541
|
const launchArgs = [...process.argv.slice(2)];
|
|
1791
1542
|
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
1792
1543
|
launchArgs.push('--cwd', workingDir);
|
|
@@ -1815,11 +1566,6 @@ export async function main() {
|
|
|
1815
1566
|
lastStartedAt: new Date().toISOString(),
|
|
1816
1567
|
lastHeartbeatAt: new Date().toISOString(),
|
|
1817
1568
|
});
|
|
1818
|
-
const runtimeState = createRuntimeStatePublisher({
|
|
1819
|
-
agentId,
|
|
1820
|
-
clientType: 'claude-code',
|
|
1821
|
-
hostMode: true,
|
|
1822
|
-
});
|
|
1823
1569
|
let streamConnected = false;
|
|
1824
1570
|
const hostAvailableExecutionModes = [
|
|
1825
1571
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1844,11 +1590,14 @@ export async function main() {
|
|
|
1844
1590
|
presentation: runtimePresentation,
|
|
1845
1591
|
}),
|
|
1846
1592
|
};
|
|
1847
|
-
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1593
|
+
// ── Conversation directory (shared inbound-ingestion bookkeeping from @canonmsg/agent-host) ──
|
|
1594
|
+
const directory = createConversationDirectory({
|
|
1595
|
+
agentId,
|
|
1596
|
+
ownerId,
|
|
1597
|
+
ownerName,
|
|
1598
|
+
refreshFloorMs: HEARTBEAT_MS,
|
|
1599
|
+
}, { port });
|
|
1600
|
+
const { conversationCache, knownConversationIds, getConversationMeta, refreshKnownConversationIds, handleConversationUpdated, markGroupContextModeUsed, loadHydratedInboundContext, } = directory;
|
|
1852
1601
|
const publishRuntimeHeartbeat = async () => {
|
|
1853
1602
|
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
1854
1603
|
agentId,
|
|
@@ -1869,18 +1618,17 @@ export async function main() {
|
|
|
1869
1618
|
console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
|
|
1870
1619
|
return runtimeMetadata;
|
|
1871
1620
|
});
|
|
1872
|
-
await publishAgentRuntime(
|
|
1621
|
+
await publishAgentRuntime(port, runtimeDescriptor).catch((error) => {
|
|
1873
1622
|
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
1874
1623
|
});
|
|
1875
|
-
await publishHostSessionSnapshots({
|
|
1624
|
+
await port.publishHostSessionSnapshots({
|
|
1876
1625
|
conversationIds: Array.from(knownConversationIds),
|
|
1877
|
-
agentId,
|
|
1878
1626
|
clientType: 'claude-code',
|
|
1879
1627
|
runtime: runtimeDescriptor,
|
|
1880
|
-
workspaceOptions,
|
|
1628
|
+
workspaceOptions: workspaceOptions.map(({ id, cwd }) => ({ id, cwd })),
|
|
1881
1629
|
defaultCwd: workingDir,
|
|
1882
1630
|
extraSessionConfigFields: ['permissionMode', 'effort'],
|
|
1883
|
-
liveSessionConfigByConversation:
|
|
1631
|
+
liveSessionConfigByConversation: Object.fromEntries(Array.from(sessions.values()).map((session) => {
|
|
1884
1632
|
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
1885
1633
|
return [
|
|
1886
1634
|
session.conversationId,
|
|
@@ -1933,7 +1681,10 @@ export async function main() {
|
|
|
1933
1681
|
worktreePath: session?.environment.worktreePath ?? null,
|
|
1934
1682
|
fallbackReason: resolveExecutionFallbackReason(session?.environment),
|
|
1935
1683
|
});
|
|
1936
|
-
await
|
|
1684
|
+
await port.publishRuntimeStatus({
|
|
1685
|
+
conversationId,
|
|
1686
|
+
presentation: payload,
|
|
1687
|
+
});
|
|
1937
1688
|
})).catch((error) => {
|
|
1938
1689
|
console.error('[canon-host] Failed to publish runtime info:', error);
|
|
1939
1690
|
});
|
|
@@ -1975,14 +1726,12 @@ export async function main() {
|
|
|
1975
1726
|
};
|
|
1976
1727
|
}
|
|
1977
1728
|
try {
|
|
1978
|
-
const convos = await
|
|
1979
|
-
|
|
1729
|
+
const convos = await directory.listConversations();
|
|
1730
|
+
directory.primeFromStartup(convos);
|
|
1980
1731
|
for (const conversation of convos) {
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
runtimeState.clearSessionState(conversation.id).catch(() => { });
|
|
1985
|
-
runtimeState.clearTurnState(conversation.id).catch(() => { });
|
|
1732
|
+
port.clearStreaming({ conversationId: conversation.id }).catch(() => { });
|
|
1733
|
+
port.clearSessionState({ conversationId: conversation.id }).catch(() => { });
|
|
1734
|
+
port.clearTurnState({ conversationId: conversation.id }).catch(() => { });
|
|
1986
1735
|
typingSignals.clear(conversation.id).catch(() => { });
|
|
1987
1736
|
}
|
|
1988
1737
|
console.error(`[canon-host] ${convos.length} conversations`);
|
|
@@ -1990,64 +1739,68 @@ export async function main() {
|
|
|
1990
1739
|
catch {
|
|
1991
1740
|
console.error('[canon-host] Could not load conversations');
|
|
1992
1741
|
}
|
|
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
|
-
|
|
1742
|
+
// ── Session manager (generic lifecycle from @canonmsg/agent-host) ──
|
|
1743
|
+
const approvalManager = ownerId
|
|
1744
|
+
? new ApprovalManager(createPortApprovalClient(port), agentId, ownerId)
|
|
1745
|
+
: null;
|
|
1746
|
+
const lifecycle = createSessionLifecycle({
|
|
1747
|
+
maxSessions: MAX_SESSIONS,
|
|
1748
|
+
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
|
1749
|
+
heartbeatMs: HEARTBEAT_MS,
|
|
1750
|
+
idleCheckMs: IDLE_CHECK_MS,
|
|
1751
|
+
logPrefix: '[canon-host]',
|
|
1752
|
+
}, {
|
|
1753
|
+
knownConversationIds,
|
|
1754
|
+
createSession: (conversationId) => createSessionForConversation(conversationId),
|
|
1755
|
+
isSessionRunning: (session) => session.state.state === 'running',
|
|
1756
|
+
writeTurnOnHeartbeat: (session) => session.state.state !== 'running' && session.state.state !== 'requires_action',
|
|
1757
|
+
writeSessionState: (session) => session.writeState(),
|
|
1758
|
+
writeSessionTurn: (session) => session.writeTurn(),
|
|
1759
|
+
releaseEnvironment: (session) => releaseConversationEnvironment(session.environment),
|
|
1760
|
+
persistResumeState,
|
|
1761
|
+
disposeRuntime: (session) => session.query.close(),
|
|
1762
|
+
interruptRuntime: (session) => session.query.interrupt(),
|
|
1763
|
+
port,
|
|
1764
|
+
publishRuntimeHeartbeat,
|
|
1765
|
+
onShutdown: async () => {
|
|
1766
|
+
detachControlNotifications();
|
|
1767
|
+
notifications.detach();
|
|
1768
|
+
approvalManager?.dispose();
|
|
1769
|
+
await port.clearAgentRuntime().catch(() => { });
|
|
1770
|
+
bridge.client.close();
|
|
2022
1771
|
},
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
|
|
2027
|
-
console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
|
|
1772
|
+
onShutdownComplete: () => {
|
|
1773
|
+
markLocalRuntimeStopped(runtimeId);
|
|
1774
|
+
lockHandle?.release();
|
|
2028
1775
|
},
|
|
2029
1776
|
});
|
|
1777
|
+
const { sessions, getOrCreateSession, closeSession, resetRuntimeSession, markSessionPendingInputsRejected, removeQueuedInput, } = lifecycle;
|
|
1778
|
+
// Interrupt/stop-clear signal handling (generic shell from @canonmsg/agent-host).
|
|
1779
|
+
const handleRuntimeControlSignal = createRuntimeSignalHandler({
|
|
1780
|
+
agentId,
|
|
1781
|
+
logPrefix: '[canon-host]',
|
|
1782
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
1783
|
+
}, {
|
|
1784
|
+
sessions,
|
|
1785
|
+
port,
|
|
1786
|
+
isSessionRunning: (session) => session.state.state === 'running',
|
|
1787
|
+
interruptRuntime: (session) => session.query.interrupt(),
|
|
1788
|
+
resetSession: (session) => resetRuntimeSession(session),
|
|
1789
|
+
markSessionPendingInputsRejected,
|
|
1790
|
+
});
|
|
1791
|
+
// Control events arrive as bridge notifications — the bridge owns the ONE
|
|
1792
|
+
// authoritative /control consumer for this identity (plan §2); the host's
|
|
1793
|
+
// adaptive poller (active/idle cadence, jitter, per-session baselines) is
|
|
1794
|
+
// deleted with it.
|
|
1795
|
+
const detachControlNotifications = attachHostControlNotifications(notifications.source, { logPrefix: '[canon-host]' }, {
|
|
1796
|
+
onSessionControl: applyRuntimeSessionControl,
|
|
1797
|
+
onControlSignal: handleRuntimeControlSignal,
|
|
1798
|
+
});
|
|
2030
1799
|
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
2031
1800
|
const savedSessionIds = new Map();
|
|
2032
|
-
const { getConversationMeta } = createConversationMetadataLoader({
|
|
2033
|
-
client,
|
|
2034
|
-
conversationCache,
|
|
2035
|
-
});
|
|
2036
1801
|
function resolveWorkspaceIdForBaseCwd(baseCwd) {
|
|
2037
1802
|
return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
|
|
2038
1803
|
}
|
|
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
1804
|
async function canRequestCanonApproval(conversationId) {
|
|
2052
1805
|
if (!ownerId)
|
|
2053
1806
|
return false;
|
|
@@ -2055,80 +1808,9 @@ export async function main() {
|
|
|
2055
1808
|
.catch(() => conversationCache.get(conversationId) ?? null);
|
|
2056
1809
|
return Array.isArray(conversation?.memberIds) && conversation.memberIds.includes(ownerId);
|
|
2057
1810
|
}
|
|
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);
|
|
1811
|
+
/** Resume-state persistence on session close (claude-specific: SDK sessionId). */
|
|
1812
|
+
function persistResumeState(session, saveResume) {
|
|
1813
|
+
const conversationId = session.conversationId;
|
|
2132
1814
|
const resumeKey = buildConversationEnvironmentKey(conversationId, session.environment.baseCwd);
|
|
2133
1815
|
if (saveResume && session.sdkSessionId) {
|
|
2134
1816
|
savedSessionIds.set(resumeKey, session.sdkSessionId);
|
|
@@ -2160,89 +1842,11 @@ export async function main() {
|
|
|
2160
1842
|
});
|
|
2161
1843
|
}
|
|
2162
1844
|
}
|
|
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
|
-
}
|
|
2207
|
-
function removeQueuedInput(conversationId, sourceMessageId) {
|
|
2208
|
-
const session = sessions.get(conversationId);
|
|
2209
|
-
if (!session || session.pendingInputs.length === 0)
|
|
2210
|
-
return;
|
|
2211
|
-
const before = session.pendingInputs.length;
|
|
2212
|
-
session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
|
|
2213
|
-
if (session.pendingInputs.length === before)
|
|
2214
|
-
return;
|
|
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
1845
|
}
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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);
|
|
1846
|
+
/** Runtime-specific session construction, invoked via the lifecycle's getOrCreateSession. */
|
|
1847
|
+
function createSessionForConversation(conversationId) {
|
|
1848
|
+
return (async () => {
|
|
1849
|
+
const config = await loadSessionConfig(port, conversationId);
|
|
2246
1850
|
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
2247
1851
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
2248
1852
|
const environment = prepareConversationEnvironment({
|
|
@@ -2259,7 +1863,7 @@ export async function main() {
|
|
|
2259
1863
|
executionMode: environment.mode,
|
|
2260
1864
|
});
|
|
2261
1865
|
const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
|
|
2262
|
-
const session = createSession(conversationId, environment, agentId,
|
|
1866
|
+
const session = createSession(conversationId, environment, agentId, port, typingSignals, (convoId, sdkSessionId) => {
|
|
2263
1867
|
// Called when session processing loop ends — save session ID for resume
|
|
2264
1868
|
const existing = sessions.get(convoId);
|
|
2265
1869
|
if (existing) {
|
|
@@ -2283,10 +1887,9 @@ export async function main() {
|
|
|
2283
1887
|
}
|
|
2284
1888
|
existing?.clearStreaming().catch(() => { });
|
|
2285
1889
|
typingSignals.clear(convoId).catch(() => { });
|
|
2286
|
-
typingSignals.dispose(convoId);
|
|
2287
1890
|
sessions.delete(convoId);
|
|
2288
|
-
|
|
2289
|
-
|
|
1891
|
+
port.clearSessionState({ conversationId: convoId }).catch(() => { });
|
|
1892
|
+
port.clearTurnState({ conversationId: convoId }).catch(() => { });
|
|
2290
1893
|
}, (descriptor) => {
|
|
2291
1894
|
runtimeDescriptor = descriptor;
|
|
2292
1895
|
void publishRuntimeHeartbeat();
|
|
@@ -2303,7 +1906,6 @@ export async function main() {
|
|
|
2303
1906
|
ownerId,
|
|
2304
1907
|
}, resumeId);
|
|
2305
1908
|
sessions.set(conversationId, session);
|
|
2306
|
-
await controlPoller.baseline([conversationId]);
|
|
2307
1909
|
return session;
|
|
2308
1910
|
}
|
|
2309
1911
|
catch (error) {
|
|
@@ -2311,13 +1913,6 @@ export async function main() {
|
|
|
2311
1913
|
throw error;
|
|
2312
1914
|
}
|
|
2313
1915
|
})();
|
|
2314
|
-
pendingSessionCreations.set(conversationId, creation);
|
|
2315
|
-
try {
|
|
2316
|
-
return await creation;
|
|
2317
|
-
}
|
|
2318
|
-
finally {
|
|
2319
|
-
pendingSessionCreations.delete(conversationId);
|
|
2320
|
-
}
|
|
2321
1916
|
}
|
|
2322
1917
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2323
1918
|
const m = input.message;
|
|
@@ -2374,7 +1969,9 @@ export async function main() {
|
|
|
2374
1969
|
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2375
1970
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
|
|
2376
1971
|
await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
|
|
2377
|
-
await
|
|
1972
|
+
await port.sendMessage({
|
|
1973
|
+
conversationId: input.conversationId,
|
|
1974
|
+
text: `I couldn't start a coding session for this workspace: ${userMessage}`,
|
|
2378
1975
|
messageId: `claude-start-failed-${m.id}`,
|
|
2379
1976
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2380
1977
|
metadata: {
|
|
@@ -2424,7 +2021,12 @@ export async function main() {
|
|
|
2424
2021
|
baseCwd: workingDir,
|
|
2425
2022
|
})?.lastInboundMessageId;
|
|
2426
2023
|
const recovery = await collectMissedInboundMessages({
|
|
2427
|
-
fetchPage: (before) =>
|
|
2024
|
+
fetchPage: (before) => port.getMessages({
|
|
2025
|
+
conversationId: conversation.id,
|
|
2026
|
+
limit: STARTUP_RECOVERY_PAGE_SIZE,
|
|
2027
|
+
...(before ? { before } : {}),
|
|
2028
|
+
includeBehavior: true,
|
|
2029
|
+
}),
|
|
2428
2030
|
cursor,
|
|
2429
2031
|
agentId,
|
|
2430
2032
|
});
|
|
@@ -2447,188 +2049,208 @@ export async function main() {
|
|
|
2447
2049
|
catch (error) {
|
|
2448
2050
|
console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
|
|
2449
2051
|
}
|
|
2450
|
-
// ──
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2052
|
+
// ── Inbound Canon events (bridge notifications) ──
|
|
2053
|
+
// The bridge owns SSE reconnect/heartbeat/replay + dedup + self-filtering;
|
|
2054
|
+
// HITL control-reply messages (approval_reply / plan_approval_reply) are
|
|
2055
|
+
// STRIPPED from onMessage bridge-side — unmatched ones arrive as
|
|
2056
|
+
// onControlReply and feed the plan-approval waiters below.
|
|
2057
|
+
const handleInboundMessage = (payload) => {
|
|
2058
|
+
const m = payload.message;
|
|
2059
|
+
if (m.senderId === agentId)
|
|
2060
|
+
return;
|
|
2061
|
+
const convoId = payload.conversationId;
|
|
2062
|
+
knownConversationIds.add(convoId);
|
|
2063
|
+
const sender = m.senderName || m.senderId;
|
|
2064
|
+
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2065
|
+
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2066
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
void (async () => {
|
|
2070
|
+
let materialized = [];
|
|
2071
|
+
if (m.id) {
|
|
2072
|
+
try {
|
|
2073
|
+
materialized = await materializeMessageMedia({
|
|
2074
|
+
id: m.id,
|
|
2075
|
+
attachments: m.attachments ?? [],
|
|
2076
|
+
}, { agentId, conversationId: convoId });
|
|
2474
2077
|
}
|
|
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
|
-
}
|
|
2078
|
+
catch (error) {
|
|
2079
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
2501
2080
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2081
|
+
}
|
|
2082
|
+
const content = renderInboundContent(m, materialized);
|
|
2083
|
+
const hydrated = await loadHydratedInboundContext({
|
|
2084
|
+
conversationId: convoId,
|
|
2085
|
+
message: m,
|
|
2086
|
+
senderName: sender,
|
|
2087
|
+
isOwner,
|
|
2088
|
+
activeSelfContextId: payload.activeSelfContextId,
|
|
2089
|
+
selfContexts: payload.selfContexts,
|
|
2090
|
+
provenance: payload.provenance,
|
|
2091
|
+
});
|
|
2092
|
+
const behavior = payload.behavior ?? hydrated.behavior;
|
|
2093
|
+
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
2094
|
+
const selfContexts = hydrated.selfContexts;
|
|
2095
|
+
const replyMedia = await materializePromptReplyContext({
|
|
2096
|
+
replyContext: hydrated.replyContext,
|
|
2097
|
+
agentId,
|
|
2098
|
+
conversationId: convoId,
|
|
2099
|
+
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
2100
|
+
});
|
|
2101
|
+
const replyContext = replyMedia.replyContext;
|
|
2102
|
+
const participantContext = hydrated.participantContext;
|
|
2103
|
+
const autoReply = payload.turnDispatch?.kind === 'run_turn'
|
|
2104
|
+
? {
|
|
2105
|
+
allow: true,
|
|
2106
|
+
reason: payload.turnDispatch.reason || 'server dispatch allowed this turn',
|
|
2507
2107
|
}
|
|
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
|
-
|
|
2108
|
+
: decideAutoReply(participantContext, behavior);
|
|
2109
|
+
if (!autoReply.allow) {
|
|
2110
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
markGroupContextModeUsed(convoId, participantContext.groupContextMode);
|
|
2114
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
2115
|
+
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
2116
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
2117
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
2118
|
+
let session;
|
|
2119
|
+
try {
|
|
2120
|
+
session = await getOrCreateSession(convoId);
|
|
2121
|
+
}
|
|
2122
|
+
catch (error) {
|
|
2123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2124
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
2125
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
2126
|
+
await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
|
|
2127
|
+
await port.sendMessage({
|
|
2128
|
+
conversationId: convoId,
|
|
2129
|
+
text: `I couldn't start a coding session for this workspace: ${userMessage}`,
|
|
2130
|
+
messageId: `claude-start-failed-${m.id}`,
|
|
2131
|
+
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
2132
|
+
metadata: {
|
|
2133
|
+
turnSemantics: 'turn_complete',
|
|
2134
|
+
replyBehavior: 'suppress_auto_reply',
|
|
2135
|
+
},
|
|
2136
|
+
}).catch(() => { });
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
session.activeSelfContextId = activeSelfContextId;
|
|
2140
|
+
const promptText = buildCanonPrompt({
|
|
2141
|
+
content,
|
|
2142
|
+
conversationId: convoId,
|
|
2143
|
+
participantContext,
|
|
2144
|
+
behavior,
|
|
2145
|
+
selfContexts,
|
|
2146
|
+
activeSelfContextId,
|
|
2147
|
+
provenance: hydrated.provenance,
|
|
2148
|
+
replyContext,
|
|
2149
|
+
message: m,
|
|
2150
|
+
});
|
|
2151
|
+
const messageContent = await buildCanonUserContent({
|
|
2152
|
+
promptText,
|
|
2153
|
+
materialized: [...replyMedia.materialized, ...materialized],
|
|
2154
|
+
});
|
|
2155
|
+
const artifactRoutingMode = resolveArtifactRoutingMode(participantContext);
|
|
2156
|
+
session.enqueueInbound({
|
|
2157
|
+
type: 'user',
|
|
2158
|
+
message: {
|
|
2159
|
+
role: 'user',
|
|
2160
|
+
content: messageContent,
|
|
2161
|
+
},
|
|
2162
|
+
parent_tool_use_id: null,
|
|
2163
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2164
|
+
if (m.id) {
|
|
2165
|
+
saveRuntimeSessionState(runtimeId, {
|
|
2166
|
+
conversationId: convoId,
|
|
2167
|
+
baseCwd: workingDir,
|
|
2168
|
+
lastInboundMessageId: m.id,
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
})();
|
|
2172
|
+
};
|
|
2173
|
+
/**
|
|
2174
|
+
* Stripped HITL control replies that matched no bridge record: the plan
|
|
2175
|
+
* approval flow (host-raised via a plain sendMessage) resolves its
|
|
2176
|
+
* metadata-reply waiter here; anything else is fed to the approval
|
|
2177
|
+
* manager as a late/second-chance consume.
|
|
2178
|
+
*/
|
|
2179
|
+
const handleControlReply = (convoId, m) => {
|
|
2180
|
+
if (m.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2181
|
+
const consumed = approvalManager?.handleMessage(convoId, {
|
|
2182
|
+
senderId: m.senderId,
|
|
2183
|
+
metadata: m.metadata,
|
|
2184
|
+
}) ?? false;
|
|
2185
|
+
if (m.metadata?.type === 'approval_reply') {
|
|
2186
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
2189
|
+
if (consumed) {
|
|
2190
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
if (m.metadata?.type === 'plan_approval_reply') {
|
|
2195
|
+
const existingSession = sessions.get(convoId);
|
|
2196
|
+
const pendingReply = existingSession?.pendingReply;
|
|
2197
|
+
const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2198
|
+
const replyRequestId = m.metadata.planId;
|
|
2199
|
+
if (!pendingReply) {
|
|
2200
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
if (!isOwnerReply) {
|
|
2204
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2208
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
if (existingSession?.pendingReply) {
|
|
2212
|
+
const { resolve } = existingSession.pendingReply;
|
|
2213
|
+
existingSession.pendingReply = null;
|
|
2214
|
+
existingSession.lastActivity = Date.now();
|
|
2215
|
+
resolve(m.metadata);
|
|
2216
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
notifications.attach({
|
|
2221
|
+
onMessage: (params) => {
|
|
2222
|
+
handleInboundMessage(params);
|
|
2223
|
+
},
|
|
2224
|
+
onMessageDeleted: (params) => {
|
|
2225
|
+
const payload = params;
|
|
2226
|
+
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
2227
|
+
},
|
|
2228
|
+
onConversationUpdated: (params) => {
|
|
2229
|
+
handleConversationUpdated(params);
|
|
2230
|
+
},
|
|
2231
|
+
onControlReply: (params) => {
|
|
2232
|
+
const payload = params;
|
|
2233
|
+
handleControlReply(payload.conversationId, payload.message);
|
|
2234
|
+
},
|
|
2235
|
+
ready: () => {
|
|
2236
|
+
streamConnected = true;
|
|
2237
|
+
void publishRuntimeHeartbeat();
|
|
2238
|
+
console.error('[canon-host] Bridge ready');
|
|
2239
|
+
},
|
|
2240
|
+
connectionState: (params) => {
|
|
2241
|
+
const { upstream } = params;
|
|
2242
|
+
if (upstream === 'connected') {
|
|
2617
2243
|
streamConnected = true;
|
|
2618
2244
|
void publishRuntimeHeartbeat();
|
|
2619
|
-
console.error('[canon-host]
|
|
2620
|
-
}
|
|
2621
|
-
|
|
2245
|
+
console.error('[canon-host] Canon upstream connected');
|
|
2246
|
+
}
|
|
2247
|
+
else {
|
|
2622
2248
|
streamConnected = false;
|
|
2623
|
-
|
|
2624
|
-
console.error(
|
|
2625
|
-
}
|
|
2626
|
-
onError: (err) => console.error(`[canon-host] SSE error: ${err.message}`),
|
|
2249
|
+
port.clearAgentRuntime().catch(() => { });
|
|
2250
|
+
console.error(`[canon-host] Canon upstream ${upstream}`);
|
|
2251
|
+
}
|
|
2627
2252
|
},
|
|
2628
2253
|
});
|
|
2629
|
-
stream.start().catch((err) => {
|
|
2630
|
-
console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
|
|
2631
|
-
});
|
|
2632
2254
|
// ── Control signal watcher ──
|
|
2633
2255
|
// Session control signals (model, mode, effort). A missing/closed session
|
|
2634
2256
|
// leaves the node in place; lastSeen still advances, so a stale control is
|
|
@@ -2687,92 +2309,9 @@ export async function main() {
|
|
|
2687
2309
|
}
|
|
2688
2310
|
session.writeState();
|
|
2689
2311
|
}
|
|
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();
|
|
2740
|
-
}
|
|
2741
|
-
}
|
|
2742
|
-
void publishRuntimeHeartbeat();
|
|
2743
|
-
}, HEARTBEAT_MS);
|
|
2744
|
-
// ── Idle session cleanup ──
|
|
2745
|
-
const idleCheck = setInterval(() => {
|
|
2746
|
-
const now = Date.now();
|
|
2747
|
-
for (const convoId of [...sessions.keys()]) {
|
|
2748
|
-
const session = sessions.get(convoId);
|
|
2749
|
-
if (!session || session.state.state === 'running')
|
|
2750
|
-
continue;
|
|
2751
|
-
if (now - session.lastActivity > IDLE_TIMEOUT_MS) {
|
|
2752
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Idle timeout`);
|
|
2753
|
-
closeSession(convoId);
|
|
2754
|
-
}
|
|
2755
|
-
}
|
|
2756
|
-
}, IDLE_CHECK_MS);
|
|
2757
|
-
// ── Graceful shutdown ──
|
|
2758
|
-
const shutdown = async () => {
|
|
2759
|
-
console.error('[canon-host] Shutting down...');
|
|
2760
|
-
controlPoller.stop();
|
|
2761
|
-
clearInterval(heartbeat);
|
|
2762
|
-
clearInterval(idleCheck);
|
|
2763
|
-
stream.stop();
|
|
2764
|
-
approvalManager?.dispose();
|
|
2765
|
-
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
2766
|
-
for (const convoId of [...sessions.keys()]) {
|
|
2767
|
-
closeSession(convoId);
|
|
2768
|
-
}
|
|
2769
|
-
markLocalRuntimeStopped(runtimeId);
|
|
2770
|
-
(lockHandle ?? getActiveProfileLock())?.release();
|
|
2771
|
-
process.exit(0);
|
|
2772
|
-
};
|
|
2773
|
-
process.on('SIGINT', shutdown);
|
|
2774
|
-
process.on('SIGTERM', shutdown);
|
|
2775
|
-
process.on('SIGHUP', shutdown);
|
|
2312
|
+
// ── Heartbeat + idle cleanup + graceful shutdown (agent-host lifecycle) ──
|
|
2313
|
+
lifecycle.startTimers();
|
|
2314
|
+
lifecycle.installSignalHandlers();
|
|
2776
2315
|
console.error('[canon-host] Ready — sessions created on demand');
|
|
2777
2316
|
// Keep alive
|
|
2778
2317
|
await new Promise(() => { });
|
|
@@ -2781,7 +2320,7 @@ export async function main() {
|
|
|
2781
2320
|
runCli(import.meta.url, main, (err) => {
|
|
2782
2321
|
const message = err instanceof Error ? err.message : String(err);
|
|
2783
2322
|
console.error(`[canon-host] ${message}`);
|
|
2784
|
-
|
|
2323
|
+
activeLockHandle?.release();
|
|
2785
2324
|
process.exit(1);
|
|
2786
2325
|
}, {
|
|
2787
2326
|
name: 'canon-claude',
|