@canonmsg/claude-code-plugin 0.15.2 → 0.19.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/dist/approval-diff.d.ts +15 -0
- package/dist/approval-diff.js +285 -0
- package/dist/host.d.ts +9 -0
- package/dist/host.js +400 -191
- package/dist/outbox.d.ts +83 -0
- package/dist/outbox.js +132 -0
- package/dist/startup-recovery.d.ts +46 -0
- package/dist/startup-recovery.js +59 -0
- package/dist/tool-policy.d.ts +7 -1
- package/dist/tool-policy.js +10 -0
- package/package.json +5 -4
package/dist/host.js
CHANGED
|
@@ -24,11 +24,17 @@ import { parseArgs } from 'node:util';
|
|
|
24
24
|
import { randomUUID } from 'node:crypto';
|
|
25
25
|
import { execFileSync } from 'node:child_process';
|
|
26
26
|
import { existsSync } from 'node:fs';
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
27
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
28
|
+
import { basename, isAbsolute, join } from 'node:path';
|
|
29
|
+
import { createSdkMcpServer, query, tool, } from '@anthropic-ai/claude-agent-sdk';
|
|
30
|
+
import { z } from 'zod';
|
|
31
|
+
import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
32
|
+
import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPlanApprovalRequest, resolveQuestionAllowOther, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCanonTurnBriefPrompt, resolveHostWorkspaceCwd, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
30
33
|
import { decideAutoReply, } from './inbound-policy.js';
|
|
31
34
|
import { runCli } from './cli-entry.js';
|
|
35
|
+
import { OUTBOX_MAX_FILE_BYTES, buildOutboxContextLine, ensureOutboxDir, flushOutbox, resolveOutboxDir, } from './outbox.js';
|
|
36
|
+
import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
37
|
+
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
32
38
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
33
39
|
import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
|
|
34
40
|
function parseRuntimeVisibilityPreset(value) {
|
|
@@ -85,6 +91,38 @@ let runtimeMetadata = {
|
|
|
85
91
|
loadedAt: 0,
|
|
86
92
|
};
|
|
87
93
|
let runtimeMetadataRefresh = null;
|
|
94
|
+
/** Bound on native slash commands exposed to the composer palette. */
|
|
95
|
+
const MAX_NATIVE_SLASH_COMMANDS = 50;
|
|
96
|
+
/**
|
|
97
|
+
* Claude Code's own slash commands (built-ins, plugins, project .claude/
|
|
98
|
+
* commands) are executable when sent as prompt text, so the runtime's command
|
|
99
|
+
* inventory is exposed as composer slash entries that pass the raw slash text
|
|
100
|
+
* through. Canon-injected aliases keep precedence over native names.
|
|
101
|
+
*/
|
|
102
|
+
export function buildNativeSlashCommandDescriptors(native, reservedAliases) {
|
|
103
|
+
const seen = new Set(reservedAliases);
|
|
104
|
+
const descriptors = [];
|
|
105
|
+
for (const command of native.slice(0, MAX_NATIVE_SLASH_COMMANDS)) {
|
|
106
|
+
const name = String(command.name ?? '').replace(/^\//, '').trim();
|
|
107
|
+
if (!name || !/^[A-Za-z0-9:_-]+$/.test(name) || seen.has(name))
|
|
108
|
+
continue;
|
|
109
|
+
seen.add(name);
|
|
110
|
+
descriptors.push({
|
|
111
|
+
id: `claude-native-${name}`,
|
|
112
|
+
label: `/${name}`,
|
|
113
|
+
description: command.description
|
|
114
|
+
?? ('argumentHint' in command ? command.argumentHint : undefined)
|
|
115
|
+
?? 'Claude Code command (runs in the runtime).',
|
|
116
|
+
aliases: [name],
|
|
117
|
+
category: 'runtime',
|
|
118
|
+
placements: ['composer_slash', 'command_palette'],
|
|
119
|
+
availability: ['always'],
|
|
120
|
+
trailingTextBehavior: 'send_as_prompt',
|
|
121
|
+
dispatch: { kind: 'text_passthrough', template: `/${name}` },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return descriptors;
|
|
125
|
+
}
|
|
88
126
|
function buildClaudeRuntimeDescriptor(input) {
|
|
89
127
|
const commands = [
|
|
90
128
|
{
|
|
@@ -166,6 +204,7 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
166
204
|
RUNTIME_STOP_ACTION,
|
|
167
205
|
RUNTIME_STOP_AND_DROP_ACTION,
|
|
168
206
|
];
|
|
207
|
+
commands.push(...buildNativeSlashCommandDescriptors(runtimeMetadata.commands, new Set(commands.flatMap((command) => [command.id, ...(command.aliases ?? [])]))));
|
|
169
208
|
return buildFirstPartyCodingRuntimeDescriptor({
|
|
170
209
|
clientType: 'claude-code',
|
|
171
210
|
models: input.models,
|
|
@@ -174,6 +213,7 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
174
213
|
executionModes: input.executionModes,
|
|
175
214
|
permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
|
|
176
215
|
defaultPermissionMode: 'default',
|
|
216
|
+
effortOptions: [...CLAUDE_EFFORT_OPTIONS],
|
|
177
217
|
streamingTextMode: 'delta',
|
|
178
218
|
turnModes: [
|
|
179
219
|
{
|
|
@@ -422,12 +462,17 @@ function mapClaudeQuestions(value) {
|
|
|
422
462
|
const optionValue = typeof optionRecord.value === 'string' && optionRecord.value.trim()
|
|
423
463
|
? optionRecord.value.trim().slice(0, 120)
|
|
424
464
|
: label;
|
|
465
|
+
const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
|
|
466
|
+
? optionRecord.description.trim().slice(0, 240)
|
|
467
|
+
: undefined;
|
|
468
|
+
const preview = typeof optionRecord.preview === 'string' && optionRecord.preview.trim()
|
|
469
|
+
? optionRecord.preview.trim().slice(0, 4000)
|
|
470
|
+
: undefined;
|
|
425
471
|
return [{
|
|
426
472
|
label,
|
|
427
473
|
value: optionValue,
|
|
428
|
-
...(
|
|
429
|
-
|
|
430
|
-
: {}),
|
|
474
|
+
...(description ? { description } : {}),
|
|
475
|
+
...(preview ? { preview } : {}),
|
|
431
476
|
}];
|
|
432
477
|
});
|
|
433
478
|
return [{
|
|
@@ -435,7 +480,12 @@ function mapClaudeQuestions(value) {
|
|
|
435
480
|
question,
|
|
436
481
|
...(header ? { header } : {}),
|
|
437
482
|
...(choices.length ? { choices } : {}),
|
|
438
|
-
|
|
483
|
+
// Mac-app parity: AskUserQuestion always offers a free-text "Other" escape
|
|
484
|
+
// hatch (the SDK adds it client-side, not via the tool schema). Default it
|
|
485
|
+
// on for choice questions unless the agent explicitly opted out.
|
|
486
|
+
...(resolveQuestionAllowOther(choices.length > 0, record.allowOther ?? record.isOther)
|
|
487
|
+
? { allowOther: true }
|
|
488
|
+
: {}),
|
|
439
489
|
...(record.isSecret === true ? { isSecret: true } : {}),
|
|
440
490
|
...(record.multiSelect === true ? { multiSelect: true } : {}),
|
|
441
491
|
}];
|
|
@@ -449,6 +499,17 @@ function flattenRuntimeInputAnswers(answers) {
|
|
|
449
499
|
}
|
|
450
500
|
return flattened;
|
|
451
501
|
}
|
|
502
|
+
/** Pre-image reader for approval diff synthesis: missing file → null (Write creates it). */
|
|
503
|
+
async function readApprovalDiffPreImage(filePath) {
|
|
504
|
+
try {
|
|
505
|
+
return await readFile(filePath, 'utf8');
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
if (error?.code === 'ENOENT')
|
|
509
|
+
return null;
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
452
513
|
function mapClaudeMcpStatus(status) {
|
|
453
514
|
switch (status) {
|
|
454
515
|
case 'connected':
|
|
@@ -518,6 +579,12 @@ function buildClaudeRuntimeInfo(input) {
|
|
|
518
579
|
value: 'Runtime policy',
|
|
519
580
|
tier: 'detail',
|
|
520
581
|
},
|
|
582
|
+
{
|
|
583
|
+
id: 'mediaOut',
|
|
584
|
+
label: 'Media out',
|
|
585
|
+
value: 'Turn-end outbox + canon_send_file',
|
|
586
|
+
tier: 'detail',
|
|
587
|
+
},
|
|
521
588
|
].filter((item) => Boolean(item));
|
|
522
589
|
return {
|
|
523
590
|
descriptor,
|
|
@@ -604,10 +671,6 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
|
604
671
|
supportsRequiresAction: true,
|
|
605
672
|
supportsNonFinalPermanentMessages: false,
|
|
606
673
|
};
|
|
607
|
-
function controlPollDelayMs(hasActiveWork) {
|
|
608
|
-
const base = hasActiveWork ? CONTROL_POLL_MS : IDLE_CONTROL_POLL_MS;
|
|
609
|
-
return base + Math.floor(Math.random() * CONTROL_POLL_JITTER_MS);
|
|
610
|
-
}
|
|
611
674
|
function buildCanonPrompt(input) {
|
|
612
675
|
const sessionContextLines = input.session ? buildClaudeSessionContextLines(input.session) : undefined;
|
|
613
676
|
return renderCanonTurnBriefPrompt(buildCanonTurnContextV2({
|
|
@@ -643,6 +706,7 @@ function buildClaudeSessionContextLines(session) {
|
|
|
643
706
|
`Working directory: ${session.cwd}`,
|
|
644
707
|
session.environment.branch ? `Git branch: ${session.environment.branch}` : null,
|
|
645
708
|
session.environment.worktreePath ? `Worktree: ${session.environment.worktreePath}` : null,
|
|
709
|
+
`${buildOutboxContextLine(session.cwd)} To send one file immediately mid-turn, use the canon_send_file tool instead.`,
|
|
646
710
|
].filter((line) => Boolean(line));
|
|
647
711
|
}
|
|
648
712
|
function renderInboundContent(message, materialized) {
|
|
@@ -785,10 +849,67 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
785
849
|
}
|
|
786
850
|
return { status: 'timeout' };
|
|
787
851
|
}
|
|
852
|
+
function buildOutboxMediaMetadata() {
|
|
853
|
+
return {
|
|
854
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
855
|
+
// Media is permanent conversation content (promoted like a final reply)
|
|
856
|
+
// but must never re-trigger other agents — the host's own final text
|
|
857
|
+
// message stays the only turn-complete trigger.
|
|
858
|
+
turnSemantics: 'turn_complete',
|
|
859
|
+
replyBehavior: 'suppress_auto_reply',
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function sendOutboxMediaFile(filePath, caption = '') {
|
|
863
|
+
return sendMediaFileMessage(client, conversationId, filePath, caption, {
|
|
864
|
+
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
865
|
+
metadata: buildOutboxMediaMetadata(),
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
// Immediate explicit media-out channel: the agent calls this tool with a
|
|
869
|
+
// path, the host uploads + sends right away. Permission flows through
|
|
870
|
+
// canUseTool like every other MCP tool (owner approval in default mode).
|
|
871
|
+
const canonMediaServer = createSdkMcpServer({
|
|
872
|
+
name: 'canon',
|
|
873
|
+
tools: [
|
|
874
|
+
tool('canon_send_file', 'Send a local file (screenshot, plot, artifact) to the current Canon conversation immediately as a media attachment.', {
|
|
875
|
+
path: z.string().describe('Path of the file to send; relative paths resolve against the session working directory.'),
|
|
876
|
+
caption: z.string().optional().describe('Optional caption shown with the attachment.'),
|
|
877
|
+
}, async (args) => {
|
|
878
|
+
try {
|
|
879
|
+
const filePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
|
|
880
|
+
const info = await stat(filePath);
|
|
881
|
+
if (!info.isFile()) {
|
|
882
|
+
throw new Error(`${filePath} is not a regular file`);
|
|
883
|
+
}
|
|
884
|
+
if (info.size > OUTBOX_MAX_FILE_BYTES) {
|
|
885
|
+
throw new Error(`File is ${info.size} bytes; the Canon media limit is ${OUTBOX_MAX_FILE_BYTES} bytes`);
|
|
886
|
+
}
|
|
887
|
+
const { messageId } = await sendOutboxMediaFile(filePath, args.caption ?? '');
|
|
888
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] canon_send_file sent ${basename(filePath)} (${messageId})`);
|
|
889
|
+
return {
|
|
890
|
+
content: [{
|
|
891
|
+
type: 'text',
|
|
892
|
+
text: `Sent ${basename(filePath)} to the Canon conversation (message ${messageId}).`,
|
|
893
|
+
}],
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
catch (error) {
|
|
897
|
+
return {
|
|
898
|
+
content: [{
|
|
899
|
+
type: 'text',
|
|
900
|
+
text: `canon_send_file failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
901
|
+
}],
|
|
902
|
+
isError: true,
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
}),
|
|
906
|
+
],
|
|
907
|
+
});
|
|
788
908
|
const claudeCliPath = resolveClaudeCliPath();
|
|
789
909
|
const queryOptions = {
|
|
790
910
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
791
911
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
912
|
+
mcpServers: { canon: canonMediaServer },
|
|
792
913
|
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
793
914
|
allowDangerouslySkipPermissions: true,
|
|
794
915
|
cwd,
|
|
@@ -872,13 +993,16 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
872
993
|
})
|
|
873
994
|
.filter((entry) => Boolean(entry))
|
|
874
995
|
: [];
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
996
|
+
// Surface the actual plan markdown on the card so the owner reviews
|
|
997
|
+
// the plan inline (Mac-app parity), not just a generic summary.
|
|
998
|
+
const planBody = typeof input.plan === 'string' && input.plan.trim()
|
|
999
|
+
? input.plan.trim()
|
|
1000
|
+
: undefined;
|
|
1001
|
+
const planApproval = buildPlanApprovalRequest(planId, 'Plan ready for review.', {
|
|
880
1002
|
summary: 'Claude has prepared a plan and is asking permission to continue.',
|
|
1003
|
+
...(planBody ? { body: planBody } : {}),
|
|
881
1004
|
...(cardResponseUser ?? {}),
|
|
1005
|
+
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
882
1006
|
});
|
|
883
1007
|
await sendMessageWithRetry(client, conversationId, planApproval.text, {
|
|
884
1008
|
messageId: `claude-plan-${planId}`,
|
|
@@ -894,11 +1018,57 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
894
1018
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
|
|
895
1019
|
const reply = await waitForReply('plan_approval_reply', planId);
|
|
896
1020
|
if (reply.decision === 'approve') {
|
|
1021
|
+
// Honor opt-in selective grants: when the card sent an explicit
|
|
1022
|
+
// granted subset, pre-approve those tools as session rules so they
|
|
1023
|
+
// do not re-prompt. Owner-turn only; absent grants preserve the
|
|
1024
|
+
// legacy behavior (each tool still prompts individually).
|
|
1025
|
+
const isOwnerTurn = session.activeInput?.kind === 'canon'
|
|
1026
|
+
? session.activeInput.isOwnerTurn === true
|
|
1027
|
+
: true;
|
|
1028
|
+
if (isOwnerTurn && config.approvalManager && Array.isArray(reply.grantedPrompts)) {
|
|
1029
|
+
// Only pre-approve tools the plan actually offered, and bound the
|
|
1030
|
+
// count/length to match parseSessionRule's caps — grantedPrompts
|
|
1031
|
+
// is untrusted wire metadata, not the validated approval path.
|
|
1032
|
+
const offeredTools = new Set(allowedPrompts.map((entry) => entry.tool));
|
|
1033
|
+
const grantedTools = new Set();
|
|
1034
|
+
for (const grant of reply.grantedPrompts) {
|
|
1035
|
+
if (grantedTools.size >= 32)
|
|
1036
|
+
break;
|
|
1037
|
+
const tool = typeof grant?.tool === 'string' ? grant.tool.trim() : '';
|
|
1038
|
+
if (tool && tool !== 'Tool' && tool.length <= 128 && offeredTools.has(tool)) {
|
|
1039
|
+
grantedTools.add(tool);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
for (const tool of grantedTools) {
|
|
1043
|
+
config.approvalManager.addSessionRule(conversationId, {
|
|
1044
|
+
type: 'approve-tool',
|
|
1045
|
+
toolPattern: tool.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
if (grantedTools.size > 0) {
|
|
1049
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan granted ${grantedTools.size} tool(s) for session`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
// The SDK leaves plan mode when we allow ExitPlanMode, but it does
|
|
1053
|
+
// not notify the host, so reset the mirrored permission mode here so
|
|
1054
|
+
// the composer turn-mode toggle and permission chip stop reporting
|
|
1055
|
+
// 'plan'. Approve-only: on reject/revise the SDK stays in plan mode.
|
|
1056
|
+
if (session.state.permissionMode === 'plan') {
|
|
1057
|
+
session.state.permissionMode = 'default';
|
|
1058
|
+
writeState();
|
|
1059
|
+
}
|
|
897
1060
|
return { behavior: 'allow' };
|
|
898
1061
|
}
|
|
1062
|
+
if (reply.decision === 'reject') {
|
|
1063
|
+
return {
|
|
1064
|
+
behavior: 'deny',
|
|
1065
|
+
message: reply.feedback?.trim()
|
|
1066
|
+
|| 'Plan declined — keep planning and wait for guidance before proceeding.',
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
899
1069
|
return {
|
|
900
1070
|
behavior: 'deny',
|
|
901
|
-
message: reply.feedback || 'User requested changes',
|
|
1071
|
+
message: reply.feedback?.trim() || 'User requested changes to the plan.',
|
|
902
1072
|
};
|
|
903
1073
|
}
|
|
904
1074
|
catch {
|
|
@@ -918,6 +1088,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
918
1088
|
approvalManager: config.approvalManager,
|
|
919
1089
|
canRequestApproval: config.canRequestApproval,
|
|
920
1090
|
allowedNonOwnerTools: allowedNonOwnerClaudeTools,
|
|
1091
|
+
// File-change preview for Edit/Write-class approvals: canUseTool fires
|
|
1092
|
+
// before the mutation lands, so read the pre-image from disk and splice
|
|
1093
|
+
// the edit in memory. Best-effort — failure logs and omits the diff.
|
|
1094
|
+
buildApprovalDiff: async () => {
|
|
1095
|
+
try {
|
|
1096
|
+
return await synthesizeClaudeApprovalDiff(toolName, input, {
|
|
1097
|
+
cwd,
|
|
1098
|
+
readFile: readApprovalDiffPreImage,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
catch (error) {
|
|
1102
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Approval diff synthesis failed (${toolName}): ${error instanceof Error ? error.message : String(error)}`);
|
|
1103
|
+
return undefined;
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
921
1106
|
});
|
|
922
1107
|
},
|
|
923
1108
|
};
|
|
@@ -1115,6 +1300,34 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1115
1300
|
return false;
|
|
1116
1301
|
}
|
|
1117
1302
|
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Turn-end media outbox flush (see ./outbox.ts for the convention). Never
|
|
1305
|
+
* throws: upload failures are logged and the files stay in place for a
|
|
1306
|
+
* later turn.
|
|
1307
|
+
*/
|
|
1308
|
+
async function flushSessionOutbox() {
|
|
1309
|
+
try {
|
|
1310
|
+
const result = await flushOutbox({
|
|
1311
|
+
outboxDir: resolveOutboxDir(cwd),
|
|
1312
|
+
send: (file) => sendOutboxMediaFile(file.path),
|
|
1313
|
+
});
|
|
1314
|
+
for (const entry of result.sent) {
|
|
1315
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent outbox file ${entry.file.fileName} (${entry.messageId})`
|
|
1316
|
+
+ (entry.removeFailed ? ' — removal failed; it may resend next turn' : ''));
|
|
1317
|
+
}
|
|
1318
|
+
for (const failure of result.failed) {
|
|
1319
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox upload failed for ${failure.file.fileName}; left in place: ${failure.error}`);
|
|
1320
|
+
}
|
|
1321
|
+
for (const skipped of result.skipped) {
|
|
1322
|
+
if (skipped.reason === 'too-large' || skipped.reason === 'file-cap') {
|
|
1323
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox skipped ${skipped.fileName} (${skipped.reason}); left in place`);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
catch (error) {
|
|
1328
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Outbox flush failed:`, error instanceof Error ? error.message : error);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1118
1331
|
function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
|
|
1119
1332
|
if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
|
|
1120
1333
|
return;
|
|
@@ -1401,6 +1614,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1401
1614
|
session.sdkSessionId = resultSessionId;
|
|
1402
1615
|
const completedInput = session.activeInput;
|
|
1403
1616
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
1617
|
+
// Turn-end outbox flush — media lands before the final text reply.
|
|
1618
|
+
// Interrupted turns keep their files for the next completed turn.
|
|
1619
|
+
if (completedInput?.kind === 'canon'
|
|
1620
|
+
&& !session.interruptedTurnKeys.has(completedInput.turnKey)) {
|
|
1621
|
+
await flushSessionOutbox();
|
|
1622
|
+
}
|
|
1404
1623
|
const resultText = typeof msg.result === 'string'
|
|
1405
1624
|
? msg.result.trim()
|
|
1406
1625
|
: '';
|
|
@@ -1462,6 +1681,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1462
1681
|
// Start processing loop and initialize session
|
|
1463
1682
|
const init = async () => {
|
|
1464
1683
|
processMessages().catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] processMessages crashed:`, err));
|
|
1684
|
+
ensureOutboxDir(cwd).catch((err) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to create media outbox:`, err));
|
|
1465
1685
|
await q.initializationResult();
|
|
1466
1686
|
if (config.model) {
|
|
1467
1687
|
try {
|
|
@@ -1570,7 +1790,7 @@ export async function main() {
|
|
|
1570
1790
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
1571
1791
|
// ── Canon API client + RTDB auth ──
|
|
1572
1792
|
const client = new CanonClient(apiKey, baseUrl);
|
|
1573
|
-
initRTDBAuth(client);
|
|
1793
|
+
const rtdb = initRTDBAuth(client);
|
|
1574
1794
|
const typingSignals = createTypingStatusPublisher({
|
|
1575
1795
|
setTyping: (conversationId, typing, status) => status
|
|
1576
1796
|
? client.setTyping(conversationId, typing, status)
|
|
@@ -1711,6 +1931,21 @@ export async function main() {
|
|
|
1711
1931
|
}).catch((error) => {
|
|
1712
1932
|
console.error('[canon-host] Failed to publish session snapshots:', error);
|
|
1713
1933
|
});
|
|
1934
|
+
// Rebuild so the descriptor picks up native slash commands from the
|
|
1935
|
+
// metadata refresh above — the initial descriptor is built before the
|
|
1936
|
+
// command inventory has loaded.
|
|
1937
|
+
if (runtimeDescriptor.runtimeDescriptor) {
|
|
1938
|
+
runtimeDescriptor = {
|
|
1939
|
+
...runtimeDescriptor,
|
|
1940
|
+
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1941
|
+
models: runtimeModels,
|
|
1942
|
+
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1943
|
+
workspaceRoots: workspaceRootMetadata,
|
|
1944
|
+
executionModes: hostAvailableExecutionModes,
|
|
1945
|
+
presentation: runtimePresentation,
|
|
1946
|
+
}),
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1714
1949
|
await Promise.all(Array.from(knownConversationIds).map(async (conversationId) => {
|
|
1715
1950
|
const session = sessions.get(conversationId);
|
|
1716
1951
|
const workspaceId = session
|
|
@@ -1793,9 +2028,39 @@ export async function main() {
|
|
|
1793
2028
|
const sessions = new Map();
|
|
1794
2029
|
const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
|
|
1795
2030
|
const pendingSessionCreations = new Map();
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
2031
|
+
// Shared /control channel poller (claude host profile): session + signal
|
|
2032
|
+
// keys, sequential conversations, active/idle cadence + jitter sampled at
|
|
2033
|
+
// cycle start, eager baselines at session creation, and nodes left in place
|
|
2034
|
+
// when a handler throws (consumeOnError defaults to false for both keys).
|
|
2035
|
+
const controlPoller = new ControlChannelPoller({
|
|
2036
|
+
rtdb,
|
|
2037
|
+
agentId,
|
|
2038
|
+
conversationIds: () => sessions.keys(),
|
|
2039
|
+
cadence: {
|
|
2040
|
+
kind: 'adaptive',
|
|
2041
|
+
activeMs: CONTROL_POLL_MS,
|
|
2042
|
+
idleMs: IDLE_CONTROL_POLL_MS,
|
|
2043
|
+
jitterMs: CONTROL_POLL_JITTER_MS,
|
|
2044
|
+
hasActiveWork: () => [...sessions.values()].some((session) => !session.closed
|
|
2045
|
+
&& (session.state.state === 'running'
|
|
2046
|
+
|| session.state.state === 'requires_action'
|
|
2047
|
+
|| session.pendingInputs.length > 0
|
|
2048
|
+
|| Boolean(session.pendingFinalDelivery))),
|
|
2049
|
+
activitySample: 'cycle-start',
|
|
2050
|
+
},
|
|
2051
|
+
pollOnStart: true,
|
|
2052
|
+
conversationConcurrency: 'sequential',
|
|
2053
|
+
handlers: {
|
|
2054
|
+
session: { handle: applyRuntimeSessionControl },
|
|
2055
|
+
signal: { handle: handleRuntimeControlSignal },
|
|
2056
|
+
},
|
|
2057
|
+
onError: ({ scope, key, conversationId, error }) => {
|
|
2058
|
+
if (scope === 'read')
|
|
2059
|
+
return; // Transient RTDB errors — retry next cycle
|
|
2060
|
+
const convo = conversationId ? ` [${conversationId.slice(0, 8)}]` : '';
|
|
2061
|
+
console.error(`[canon-host]${convo} Control ${key ?? 'poll'} ${scope} error:`, error);
|
|
2062
|
+
},
|
|
2063
|
+
});
|
|
1799
2064
|
/** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
|
|
1800
2065
|
const savedSessionIds = new Map();
|
|
1801
2066
|
const { getConversationMeta } = createConversationMetadataLoader({
|
|
@@ -2072,10 +2337,7 @@ export async function main() {
|
|
|
2072
2337
|
ownerId,
|
|
2073
2338
|
}, resumeId);
|
|
2074
2339
|
sessions.set(conversationId, session);
|
|
2075
|
-
await
|
|
2076
|
-
baselineControlSignal(conversationId),
|
|
2077
|
-
baselineSessionControl(conversationId),
|
|
2078
|
-
]);
|
|
2340
|
+
await controlPoller.baseline([conversationId]);
|
|
2079
2341
|
return session;
|
|
2080
2342
|
}
|
|
2081
2343
|
catch (error) {
|
|
@@ -2091,28 +2353,6 @@ export async function main() {
|
|
|
2091
2353
|
pendingSessionCreations.delete(conversationId);
|
|
2092
2354
|
}
|
|
2093
2355
|
}
|
|
2094
|
-
async function baselineControlSignal(conversationId) {
|
|
2095
|
-
if (lastSeenSignal.has(conversationId))
|
|
2096
|
-
return;
|
|
2097
|
-
const raw = await rtdbRead(`/control/${conversationId}/${agentId}/signal`).catch(() => null);
|
|
2098
|
-
if (!raw || typeof raw !== 'object')
|
|
2099
|
-
return;
|
|
2100
|
-
const timestamp = Number(raw.updatedAt ?? 0);
|
|
2101
|
-
if (timestamp > 0) {
|
|
2102
|
-
lastSeenSignal.set(conversationId, timestamp);
|
|
2103
|
-
}
|
|
2104
|
-
}
|
|
2105
|
-
async function baselineSessionControl(conversationId) {
|
|
2106
|
-
if (lastSeenControl.has(conversationId))
|
|
2107
|
-
return;
|
|
2108
|
-
const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
|
|
2109
|
-
if (!raw || typeof raw !== 'object')
|
|
2110
|
-
return;
|
|
2111
|
-
const timestamp = Number(raw.updatedAt ?? 0);
|
|
2112
|
-
if (timestamp > 0) {
|
|
2113
|
-
lastSeenControl.set(conversationId, timestamp);
|
|
2114
|
-
}
|
|
2115
|
-
}
|
|
2116
2356
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2117
2357
|
const m = input.message;
|
|
2118
2358
|
if (m.senderId === agentId)
|
|
@@ -2217,22 +2457,23 @@ export async function main() {
|
|
|
2217
2457
|
conversationId: conversation.id,
|
|
2218
2458
|
baseCwd: workingDir,
|
|
2219
2459
|
})?.lastInboundMessageId;
|
|
2220
|
-
const
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2460
|
+
const recovery = await collectMissedInboundMessages({
|
|
2461
|
+
fetchPage: (before) => client.getMessagesPage(conversation.id, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
2462
|
+
cursor,
|
|
2463
|
+
agentId,
|
|
2464
|
+
});
|
|
2465
|
+
if (recovery.mode === 'truncated-window') {
|
|
2466
|
+
console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Startup recovery cursor not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; recovering truncated window`);
|
|
2467
|
+
}
|
|
2468
|
+
if (recovery.messages.length > 1) {
|
|
2469
|
+
console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovered ${recovery.messages.length} missed messages in ${conversation.id}`);
|
|
2470
|
+
}
|
|
2471
|
+
for (const message of recovery.messages) {
|
|
2231
2472
|
console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
|
|
2232
2473
|
await enqueueRecoveredInboundMessage({
|
|
2233
2474
|
conversationId: conversation.id,
|
|
2234
2475
|
message: message,
|
|
2235
|
-
hydratedPage:
|
|
2476
|
+
hydratedPage: recovery.newestPage,
|
|
2236
2477
|
});
|
|
2237
2478
|
}
|
|
2238
2479
|
}
|
|
@@ -2425,139 +2666,107 @@ export async function main() {
|
|
|
2425
2666
|
console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
|
|
2426
2667
|
});
|
|
2427
2668
|
// ── Control signal watcher ──
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
try {
|
|
2449
|
-
await session.query.setModel(ctrl.model);
|
|
2450
|
-
session.state.model = ctrl.model;
|
|
2451
|
-
session.clearRuntimeControlError('model');
|
|
2452
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
|
|
2453
|
-
}
|
|
2454
|
-
catch (err) {
|
|
2455
|
-
session.setRuntimeControlError('model', ctrl.model, err);
|
|
2456
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
|
|
2457
|
-
}
|
|
2458
|
-
}
|
|
2459
|
-
const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
|
|
2460
|
-
if (ctrl.permissionMode && !nextPermissionMode) {
|
|
2461
|
-
session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
|
|
2462
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
|
|
2463
|
-
}
|
|
2464
|
-
else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
|
|
2465
|
-
try {
|
|
2466
|
-
await session.query.setPermissionMode(nextPermissionMode);
|
|
2467
|
-
session.state.permissionMode = nextPermissionMode;
|
|
2468
|
-
session.clearRuntimeControlError('permissionMode');
|
|
2469
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
|
|
2470
|
-
}
|
|
2471
|
-
catch (err) {
|
|
2472
|
-
session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
|
|
2473
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
|
|
2474
|
-
}
|
|
2475
|
-
}
|
|
2476
|
-
const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
|
|
2477
|
-
if (nextEffort && nextEffort !== session.state.effort) {
|
|
2478
|
-
try {
|
|
2479
|
-
await applyClaudeEffortLevel(session.query, nextEffort);
|
|
2480
|
-
session.state.effort = nextEffort;
|
|
2481
|
-
session.clearRuntimeControlError('effort');
|
|
2482
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
|
|
2483
|
-
}
|
|
2484
|
-
catch (err) {
|
|
2485
|
-
session.setRuntimeControlError('effort', nextEffort, err);
|
|
2486
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
else if (ctrl.effort && !nextEffort) {
|
|
2490
|
-
session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
|
|
2491
|
-
}
|
|
2492
|
-
session.writeState();
|
|
2493
|
-
await rtdbWrite(`/control/${convoId}/${agentId}/session`, null).catch(() => { });
|
|
2494
|
-
}
|
|
2495
|
-
}
|
|
2496
|
-
}
|
|
2497
|
-
// Interrupt/stop-clear signal
|
|
2498
|
-
const sig = await rtdbRead(`/control/${convoId}/${agentId}/signal`);
|
|
2499
|
-
if (sig && typeof sig === 'object') {
|
|
2500
|
-
const s = sig;
|
|
2501
|
-
const ts = s.updatedAt ?? 0;
|
|
2502
|
-
if ((s.type === 'interrupt' || s.type === 'stop_and_drop' || s.type === 'new_session')
|
|
2503
|
-
&& ts > (lastSeenSignal.get(convoId) ?? 0)) {
|
|
2504
|
-
lastSeenSignal.set(convoId, ts);
|
|
2505
|
-
const session = sessions.get(convoId);
|
|
2506
|
-
if (session && !session.closed) {
|
|
2507
|
-
if (s.type === 'new_session') {
|
|
2508
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2509
|
-
await resetRuntimeSession(session);
|
|
2510
|
-
await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
|
|
2511
|
-
continue;
|
|
2512
|
-
}
|
|
2513
|
-
const isActive = session.state.state === 'running';
|
|
2514
|
-
if (!isActive && (s.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2515
|
-
await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
|
|
2516
|
-
continue;
|
|
2517
|
-
}
|
|
2518
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${s.type} signal`);
|
|
2519
|
-
try {
|
|
2520
|
-
if (s.type === 'stop_and_drop') {
|
|
2521
|
-
const droppedInputs = session.pendingInputs.splice(0);
|
|
2522
|
-
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2523
|
-
}
|
|
2524
|
-
if (session.activeInput) {
|
|
2525
|
-
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2526
|
-
}
|
|
2527
|
-
session.pendingFinalText = null;
|
|
2528
|
-
session.turnState = 'interrupted';
|
|
2529
|
-
session.clearStreaming().catch(() => { });
|
|
2530
|
-
typingSignals.clear(convoId).catch(() => { });
|
|
2531
|
-
runtimeState.writeTurnState(convoId, {
|
|
2532
|
-
turnId: session.currentTurnId,
|
|
2533
|
-
state: 'interrupted',
|
|
2534
|
-
queueDepth: session.pendingInputs.length,
|
|
2535
|
-
currentSpeakerId: agentId,
|
|
2536
|
-
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
2537
|
-
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
2538
|
-
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2539
|
-
completedAt: { '.sv': 'timestamp' },
|
|
2540
|
-
}).catch(() => { });
|
|
2541
|
-
if (isActive) {
|
|
2542
|
-
await session.query.interrupt();
|
|
2543
|
-
}
|
|
2544
|
-
await rtdbWrite(`/control/${convoId}/${agentId}/signal`, null).catch(() => { });
|
|
2545
|
-
}
|
|
2546
|
-
catch (err) {
|
|
2547
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${s.type} failed:`, err);
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
}
|
|
2551
|
-
}
|
|
2552
|
-
}
|
|
2553
|
-
catch {
|
|
2554
|
-
// Transient RTDB errors — ignore and retry next cycle
|
|
2555
|
-
}
|
|
2669
|
+
// Session control signals (model, mode, effort). A missing/closed session
|
|
2670
|
+
// leaves the node in place; lastSeen still advances, so a stale control is
|
|
2671
|
+
// never replayed once a session exists.
|
|
2672
|
+
async function applyRuntimeSessionControl(event) {
|
|
2673
|
+
const convoId = event.conversationId;
|
|
2674
|
+
const session = sessions.get(convoId);
|
|
2675
|
+
if (!session || session.closed)
|
|
2676
|
+
return { consume: false };
|
|
2677
|
+
const ctrl = event.control;
|
|
2678
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
|
|
2679
|
+
if (ctrl.model && ctrl.model !== session.state.model) {
|
|
2680
|
+
try {
|
|
2681
|
+
await session.query.setModel(ctrl.model);
|
|
2682
|
+
session.state.model = ctrl.model;
|
|
2683
|
+
session.clearRuntimeControlError('model');
|
|
2684
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
|
|
2685
|
+
}
|
|
2686
|
+
catch (err) {
|
|
2687
|
+
session.setRuntimeControlError('model', ctrl.model, err);
|
|
2688
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
|
|
2556
2689
|
}
|
|
2557
|
-
await new Promise((r) => setTimeout(r, controlPollDelayMs(hadActiveWork)));
|
|
2558
2690
|
}
|
|
2559
|
-
|
|
2560
|
-
|
|
2691
|
+
const nextPermissionMode = parseClaudePermissionMode(ctrl.permissionMode);
|
|
2692
|
+
if (ctrl.permissionMode && !nextPermissionMode) {
|
|
2693
|
+
session.setRuntimeControlError('permissionMode', ctrl.permissionMode, new Error(`Unsupported Claude permission mode: ${ctrl.permissionMode}`));
|
|
2694
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Unsupported mode: ${ctrl.permissionMode}`);
|
|
2695
|
+
}
|
|
2696
|
+
else if (nextPermissionMode && nextPermissionMode !== session.state.permissionMode) {
|
|
2697
|
+
try {
|
|
2698
|
+
await session.query.setPermissionMode(nextPermissionMode);
|
|
2699
|
+
session.state.permissionMode = nextPermissionMode;
|
|
2700
|
+
session.clearRuntimeControlError('permissionMode');
|
|
2701
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${nextPermissionMode}`);
|
|
2702
|
+
}
|
|
2703
|
+
catch (err) {
|
|
2704
|
+
session.setRuntimeControlError('permissionMode', nextPermissionMode, err);
|
|
2705
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
|
|
2709
|
+
if (nextEffort && nextEffort !== session.state.effort) {
|
|
2710
|
+
try {
|
|
2711
|
+
await applyClaudeEffortLevel(session.query, nextEffort);
|
|
2712
|
+
session.state.effort = nextEffort;
|
|
2713
|
+
session.clearRuntimeControlError('effort');
|
|
2714
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
|
|
2715
|
+
}
|
|
2716
|
+
catch (err) {
|
|
2717
|
+
session.setRuntimeControlError('effort', nextEffort, err);
|
|
2718
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
|
|
2719
|
+
}
|
|
2720
|
+
}
|
|
2721
|
+
else if (ctrl.effort && !nextEffort) {
|
|
2722
|
+
session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
|
|
2723
|
+
}
|
|
2724
|
+
session.writeState();
|
|
2725
|
+
}
|
|
2726
|
+
// Interrupt/stop-clear signal. Throwing leaves the node in place
|
|
2727
|
+
// (consumeOnError: false); lastSeen has already advanced, so the failed
|
|
2728
|
+
// signal is reported but never retried.
|
|
2729
|
+
async function handleRuntimeControlSignal(event) {
|
|
2730
|
+
const convoId = event.conversationId;
|
|
2731
|
+
const session = sessions.get(convoId);
|
|
2732
|
+
if (!session || session.closed)
|
|
2733
|
+
return { consume: false };
|
|
2734
|
+
if (event.type === 'new_session') {
|
|
2735
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2736
|
+
await resetRuntimeSession(session);
|
|
2737
|
+
return;
|
|
2738
|
+
}
|
|
2739
|
+
const isActive = session.state.state === 'running';
|
|
2740
|
+
if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2741
|
+
return; // Nothing to interrupt — consume the stale signal
|
|
2742
|
+
}
|
|
2743
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2744
|
+
if (event.type === 'stop_and_drop') {
|
|
2745
|
+
const droppedInputs = session.pendingInputs.splice(0);
|
|
2746
|
+
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2747
|
+
}
|
|
2748
|
+
if (session.activeInput) {
|
|
2749
|
+
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2750
|
+
}
|
|
2751
|
+
session.pendingFinalText = null;
|
|
2752
|
+
session.turnState = 'interrupted';
|
|
2753
|
+
session.clearStreaming().catch(() => { });
|
|
2754
|
+
typingSignals.clear(convoId).catch(() => { });
|
|
2755
|
+
runtimeState.writeTurnState(convoId, {
|
|
2756
|
+
turnId: session.currentTurnId,
|
|
2757
|
+
state: 'interrupted',
|
|
2758
|
+
queueDepth: session.pendingInputs.length,
|
|
2759
|
+
currentSpeakerId: agentId,
|
|
2760
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
2761
|
+
capabilities: CLAUDE_RUNTIME_CAPABILITIES,
|
|
2762
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2763
|
+
completedAt: { '.sv': 'timestamp' },
|
|
2764
|
+
}).catch(() => { });
|
|
2765
|
+
if (isActive) {
|
|
2766
|
+
await session.query.interrupt();
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
controlPoller.start();
|
|
2561
2770
|
// ── Heartbeat (per-session) ──
|
|
2562
2771
|
const heartbeat = setInterval(() => {
|
|
2563
2772
|
for (const session of sessions.values()) {
|
|
@@ -2584,7 +2793,7 @@ export async function main() {
|
|
|
2584
2793
|
// ── Graceful shutdown ──
|
|
2585
2794
|
const shutdown = async () => {
|
|
2586
2795
|
console.error('[canon-host] Shutting down...');
|
|
2587
|
-
|
|
2796
|
+
controlPoller.stop();
|
|
2588
2797
|
clearInterval(heartbeat);
|
|
2589
2798
|
clearInterval(idleCheck);
|
|
2590
2799
|
stream.stop();
|