@canonmsg/claude-code-plugin 0.25.0 → 0.25.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/dist/host.js +432 -233
- package/dist/session-state.d.ts +16 -3
- package/dist/session-state.js +66 -16
- package/dist/startup-recovery.d.ts +6 -3
- package/dist/startup-recovery.js +41 -13
- package/dist/user-dialog.d.ts +17 -0
- package/dist/user-dialog.js +117 -0
- package/package.json +3 -3
package/dist/host.js
CHANGED
|
@@ -28,12 +28,14 @@ import { readFile } from 'node:fs/promises';
|
|
|
28
28
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
29
29
|
import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
30
30
|
import { captureTurnArtifactSnapshot, collectTurnArtifacts, } from '@canonmsg/coding-agent-host';
|
|
31
|
-
import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds,
|
|
31
|
+
import { CLAUDE_EFFORT_OPTIONS, CLAUDE_PERMISSION_MODE_OPTIONS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildCanonInboundFrameV1, buildCanonTurnContextV2, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildFirstPartyCodingRuntimeDescriptor, buildHydratedInboundContext, diffCanonMemberIds, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, buildRuntimePresentationPolicy, normalizeRuntimeCommandDescriptors, DEFAULT_FIRST_PARTY_RUNTIME_PRESENTATION, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, readLocalRuntimeEntry, resolveCanonAgent, initRTDBAuth, sendMessageWithRetry, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
32
32
|
import { decideAutoReply, } from './inbound-policy.js';
|
|
33
33
|
import { runCli } from './cli-entry.js';
|
|
34
34
|
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
35
35
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
36
|
-
import { applyClaudeSessionControl, buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError,
|
|
36
|
+
import { applyClaudeSessionControl, buildClaudeFinalMessageId, claudeModelInfoToOption, confirmClaudeInterrupt, createClaudeInputEnvelope, formatClaudeControlError, isClaudeCustomModelOption, mergeClaudeDiscoveredModelOptions, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
|
|
37
|
+
import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, } from './user-dialog.js';
|
|
38
|
+
import { collectMissedInboundMessages, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
37
39
|
function parseRuntimeVisibilityPreset(value) {
|
|
38
40
|
return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
|
|
39
41
|
}
|
|
@@ -309,6 +311,7 @@ function resolveClaudeCliPath() {
|
|
|
309
311
|
const override = process.env.CANON_CLAUDE_CLI_PATH?.trim();
|
|
310
312
|
if (override) {
|
|
311
313
|
if (existsSync(override)) {
|
|
314
|
+
assertExternalClaudeCliVersion(override);
|
|
312
315
|
cachedClaudeCliPath = override;
|
|
313
316
|
console.error(`[canon-host] claude CLI override: ${override}`);
|
|
314
317
|
return override;
|
|
@@ -320,6 +323,7 @@ function resolveClaudeCliPath() {
|
|
|
320
323
|
encoding: 'utf8',
|
|
321
324
|
}).trim();
|
|
322
325
|
if (resolved && existsSync(resolved)) {
|
|
326
|
+
assertExternalClaudeCliVersion(resolved);
|
|
323
327
|
cachedClaudeCliPath = resolved;
|
|
324
328
|
console.error(`[canon-host] claude CLI on PATH: ${resolved}`);
|
|
325
329
|
return resolved;
|
|
@@ -332,11 +336,25 @@ function resolveClaudeCliPath() {
|
|
|
332
336
|
console.error('[canon-host] claude CLI not on PATH; using SDK-bundled binary');
|
|
333
337
|
return undefined;
|
|
334
338
|
}
|
|
339
|
+
function assertExternalClaudeCliVersion(cliPath) {
|
|
340
|
+
const output = execFileSync(cliPath, ['--version'], { encoding: 'utf8' }).trim();
|
|
341
|
+
const match = output.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
342
|
+
if (!match) {
|
|
343
|
+
throw new Error(`Could not determine Claude Code version from: ${output}`);
|
|
344
|
+
}
|
|
345
|
+
const installed = match.slice(1, 4).map(Number);
|
|
346
|
+
const minimum = [2, 1, 201];
|
|
347
|
+
const supported = installed.some((value, index) => {
|
|
348
|
+
if (value === minimum[index])
|
|
349
|
+
return false;
|
|
350
|
+
return value > minimum[index] && installed.slice(0, index).every((part, partIndex) => part === minimum[partIndex]);
|
|
351
|
+
}) || installed.every((value, index) => value === minimum[index]);
|
|
352
|
+
if (!supported) {
|
|
353
|
+
throw new Error(`Claude Code ${minimum.join('.')} or newer is required; found ${match[0]}.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
335
356
|
function toModelOptions(models) {
|
|
336
|
-
return models.map(
|
|
337
|
-
value: model.value,
|
|
338
|
-
label: model.displayName,
|
|
339
|
-
}));
|
|
357
|
+
return models.map(claudeModelInfoToOption);
|
|
340
358
|
}
|
|
341
359
|
async function publishAgentRuntime(agentId, runtime) {
|
|
342
360
|
await publishHostAgentRuntime(agentId, 'claude-code', runtime);
|
|
@@ -376,7 +394,28 @@ function resolveExecutionFallbackReason(environment) {
|
|
|
376
394
|
? null
|
|
377
395
|
: environment.reason;
|
|
378
396
|
}
|
|
397
|
+
const CLAUDE_SUPPLEMENTAL_MODEL_PROBES = ['opus', 'fable'];
|
|
379
398
|
async function detectRuntimeModels(cwd) {
|
|
399
|
+
const [discovered, supplemental] = await Promise.all([
|
|
400
|
+
detectRuntimeModelsForSelection(cwd, 'default'),
|
|
401
|
+
detectSupplementalRuntimeModels(cwd),
|
|
402
|
+
]);
|
|
403
|
+
return mergeClaudeDiscoveredModelOptions(discovered, ...supplemental);
|
|
404
|
+
}
|
|
405
|
+
async function detectSupplementalRuntimeModels(cwd) {
|
|
406
|
+
return Promise.all(CLAUDE_SUPPLEMENTAL_MODEL_PROBES.map(async (model) => {
|
|
407
|
+
try {
|
|
408
|
+
const options = await detectRuntimeModelsForSelection(cwd, model);
|
|
409
|
+
const option = options.find((entry) => entry.value === model);
|
|
410
|
+
return option && !isClaudeCustomModelOption(option) ? [option] : [];
|
|
411
|
+
}
|
|
412
|
+
catch (error) {
|
|
413
|
+
console.error(`[canon-host] Supplemental Claude model probe failed for ${model}:`, error);
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
}));
|
|
417
|
+
}
|
|
418
|
+
async function detectRuntimeModelsForSelection(cwd, model) {
|
|
380
419
|
const emptyPrompt = {
|
|
381
420
|
async *[Symbol.asyncIterator]() { },
|
|
382
421
|
};
|
|
@@ -386,6 +425,7 @@ async function detectRuntimeModels(cwd) {
|
|
|
386
425
|
options: {
|
|
387
426
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
388
427
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
428
|
+
model,
|
|
389
429
|
permissionMode: 'default',
|
|
390
430
|
cwd,
|
|
391
431
|
settings: { forceLoginMethod: 'claudeai' },
|
|
@@ -413,6 +453,7 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
|
|
|
413
453
|
options: {
|
|
414
454
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
415
455
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
456
|
+
model: 'default',
|
|
416
457
|
permissionMode: 'default',
|
|
417
458
|
cwd,
|
|
418
459
|
settings: { forceLoginMethod: 'claudeai' },
|
|
@@ -465,78 +506,6 @@ async function refreshClaudeRuntimeMetadata(input) {
|
|
|
465
506
|
function sleep(ms) {
|
|
466
507
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
467
508
|
}
|
|
468
|
-
function mapClaudeQuestions(value) {
|
|
469
|
-
if (!Array.isArray(value))
|
|
470
|
-
return undefined;
|
|
471
|
-
const questions = value.slice(0, 12).flatMap((entry) => {
|
|
472
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
473
|
-
return [];
|
|
474
|
-
const record = entry;
|
|
475
|
-
const id = typeof record.id === 'string' && record.id.trim()
|
|
476
|
-
? record.id.trim().slice(0, 120)
|
|
477
|
-
: typeof record.question === 'string' && record.question.trim()
|
|
478
|
-
? record.question.trim().slice(0, 80).replace(/[^A-Za-z0-9_.:-]+/g, '-')
|
|
479
|
-
: null;
|
|
480
|
-
const question = typeof record.question === 'string' && record.question.trim()
|
|
481
|
-
? record.question.trim().slice(0, 1000)
|
|
482
|
-
: null;
|
|
483
|
-
if (!id || !/^[A-Za-z0-9_.:-]{1,120}$/.test(id) || !question)
|
|
484
|
-
return [];
|
|
485
|
-
const header = typeof record.header === 'string' && record.header.trim()
|
|
486
|
-
? record.header.trim().slice(0, 120)
|
|
487
|
-
: undefined;
|
|
488
|
-
const rawOptions = Array.isArray(record.options) ? record.options : [];
|
|
489
|
-
const choices = rawOptions.slice(0, 12).flatMap((option) => {
|
|
490
|
-
if (!option || typeof option !== 'object' || Array.isArray(option))
|
|
491
|
-
return [];
|
|
492
|
-
const optionRecord = option;
|
|
493
|
-
const label = typeof optionRecord.label === 'string' && optionRecord.label.trim()
|
|
494
|
-
? optionRecord.label.trim().slice(0, 120)
|
|
495
|
-
: typeof optionRecord.value === 'string' && optionRecord.value.trim()
|
|
496
|
-
? optionRecord.value.trim().slice(0, 120)
|
|
497
|
-
: null;
|
|
498
|
-
if (!label)
|
|
499
|
-
return [];
|
|
500
|
-
const optionValue = typeof optionRecord.value === 'string' && optionRecord.value.trim()
|
|
501
|
-
? optionRecord.value.trim().slice(0, 120)
|
|
502
|
-
: label;
|
|
503
|
-
const description = typeof optionRecord.description === 'string' && optionRecord.description.trim()
|
|
504
|
-
? optionRecord.description.trim().slice(0, 240)
|
|
505
|
-
: undefined;
|
|
506
|
-
const preview = typeof optionRecord.preview === 'string' && optionRecord.preview.trim()
|
|
507
|
-
? optionRecord.preview.trim().slice(0, 4000)
|
|
508
|
-
: undefined;
|
|
509
|
-
return [{
|
|
510
|
-
label,
|
|
511
|
-
value: optionValue,
|
|
512
|
-
...(description ? { description } : {}),
|
|
513
|
-
...(preview ? { preview } : {}),
|
|
514
|
-
}];
|
|
515
|
-
});
|
|
516
|
-
return [{
|
|
517
|
-
id,
|
|
518
|
-
question,
|
|
519
|
-
...(header ? { header } : {}),
|
|
520
|
-
...(choices.length ? { choices } : {}),
|
|
521
|
-
// Mac-app parity: AskUserQuestion always offers a free-text "Other" escape
|
|
522
|
-
// hatch (the SDK adds it client-side, not via the tool schema). Default it
|
|
523
|
-
// on for choice questions unless the agent explicitly opted out.
|
|
524
|
-
...(resolveQuestionAllowOther(choices.length > 0, record.allowOther ?? record.isOther)
|
|
525
|
-
? { allowOther: true }
|
|
526
|
-
: {}),
|
|
527
|
-
...(record.isSecret === true ? { isSecret: true } : {}),
|
|
528
|
-
...(record.multiSelect === true ? { multiSelect: true } : {}),
|
|
529
|
-
}];
|
|
530
|
-
});
|
|
531
|
-
return questions.length ? questions : undefined;
|
|
532
|
-
}
|
|
533
|
-
function flattenRuntimeInputAnswers(answers) {
|
|
534
|
-
const flattened = {};
|
|
535
|
-
for (const [questionId, answer] of Object.entries(answers ?? {})) {
|
|
536
|
-
flattened[questionId] = answer.answers.join(', ');
|
|
537
|
-
}
|
|
538
|
-
return flattened;
|
|
539
|
-
}
|
|
540
509
|
/** Pre-image reader for approval diff synthesis: missing file → null (Write creates it). */
|
|
541
510
|
async function readApprovalDiffPreImage(filePath) {
|
|
542
511
|
try {
|
|
@@ -700,6 +669,7 @@ const IDLE_CONTROL_POLL_MS = 10_000;
|
|
|
700
669
|
const CONTROL_POLL_JITTER_MS = 1_000;
|
|
701
670
|
const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
702
671
|
const MAX_FINAL_DELIVERY_RETRIES = 1;
|
|
672
|
+
const CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID = 'claude-recovery-cursor-v1';
|
|
703
673
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
704
674
|
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
705
675
|
supportsInterrupt: true,
|
|
@@ -784,7 +754,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
784
754
|
messageQueue.push(input);
|
|
785
755
|
}
|
|
786
756
|
};
|
|
787
|
-
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
757
|
+
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
|
|
788
758
|
sendInput(createClaudeInputEnvelope({
|
|
789
759
|
kind: 'canon',
|
|
790
760
|
msg,
|
|
@@ -792,6 +762,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
792
762
|
sourceMessageId,
|
|
793
763
|
markAccepted,
|
|
794
764
|
isOwnerTurn,
|
|
765
|
+
requestingUserId,
|
|
795
766
|
artifactRoutingMode,
|
|
796
767
|
}));
|
|
797
768
|
};
|
|
@@ -825,6 +796,73 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
825
796
|
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
826
797
|
let session;
|
|
827
798
|
const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
799
|
+
const activeUserDialogs = new Map();
|
|
800
|
+
const completedUserDialogs = new Map();
|
|
801
|
+
async function requestClaudeUserDialog(dialog, nativeRequestId, signal) {
|
|
802
|
+
const questionId = /^[A-Za-z0-9_.:-]{1,160}$/.test(nativeRequestId)
|
|
803
|
+
? nativeRequestId
|
|
804
|
+
: randomUUID();
|
|
805
|
+
const completed = completedUserDialogs.get(questionId);
|
|
806
|
+
if (completed)
|
|
807
|
+
return completed;
|
|
808
|
+
const existing = activeUserDialogs.get(questionId);
|
|
809
|
+
if (existing)
|
|
810
|
+
return existing;
|
|
811
|
+
const responseUserId = session.activeInput?.requestingUserId ?? undefined;
|
|
812
|
+
if (!responseUserId) {
|
|
813
|
+
return buildClaudeAskUserPermissionDenied('No eligible human participant can answer this question.');
|
|
814
|
+
}
|
|
815
|
+
const run = (async () => {
|
|
816
|
+
try {
|
|
817
|
+
const reply = await config.runtimeInputManager.request('input', conversationId, {
|
|
818
|
+
kind: 'clarify',
|
|
819
|
+
title: 'Claude needs input',
|
|
820
|
+
prompt: 'Claude needs your input to continue.',
|
|
821
|
+
questions: dialog.questions,
|
|
822
|
+
sensitive: dialog.questions.some((question) => question.isSecret),
|
|
823
|
+
responseUserId,
|
|
824
|
+
native: {
|
|
825
|
+
runtime: 'claude-code',
|
|
826
|
+
method: 'AskUserQuestion',
|
|
827
|
+
requestId: nativeRequestId,
|
|
828
|
+
turnId: session.currentTurnId ?? undefined,
|
|
829
|
+
},
|
|
830
|
+
turnId: session.currentTurnId ?? undefined,
|
|
831
|
+
}, {
|
|
832
|
+
requestId: questionId,
|
|
833
|
+
expiresAt: Date.now() + REPLY_TIMEOUT_MS,
|
|
834
|
+
responderPolicy: 'infer',
|
|
835
|
+
signal,
|
|
836
|
+
onCreated: () => {
|
|
837
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
838
|
+
session.lastActivity = Date.now();
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
if (reply.status === 'submitted') {
|
|
842
|
+
return buildClaudeAskUserPermissionResult(dialog, reply);
|
|
843
|
+
}
|
|
844
|
+
return buildClaudeAskUserPermissionDenied(reply.status === 'timeout' ? 'No reply received (timed out)' : 'The user cancelled this question.');
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return buildClaudeAskUserPermissionDenied(signal.aborted ? 'The question was interrupted.' : 'No reply received (timed out)');
|
|
848
|
+
}
|
|
849
|
+
})();
|
|
850
|
+
activeUserDialogs.set(questionId, run);
|
|
851
|
+
try {
|
|
852
|
+
const result = await run;
|
|
853
|
+
completedUserDialogs.set(questionId, result);
|
|
854
|
+
while (completedUserDialogs.size > 64) {
|
|
855
|
+
const oldest = completedUserDialogs.keys().next().value;
|
|
856
|
+
if (oldest === undefined)
|
|
857
|
+
break;
|
|
858
|
+
completedUserDialogs.delete(oldest);
|
|
859
|
+
}
|
|
860
|
+
return result;
|
|
861
|
+
}
|
|
862
|
+
finally {
|
|
863
|
+
activeUserDialogs.delete(questionId);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
828
866
|
function waitForReply(type, requestId) {
|
|
829
867
|
// Keep session alive while waiting for user input
|
|
830
868
|
session.lastActivity = Date.now();
|
|
@@ -857,6 +895,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
857
895
|
const queryOptions = {
|
|
858
896
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
859
897
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
898
|
+
model: 'default',
|
|
860
899
|
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
861
900
|
allowDangerouslySkipPermissions: true,
|
|
862
901
|
cwd,
|
|
@@ -872,59 +911,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
872
911
|
console.error(`[claude-cli] [${conversationId.slice(0, 8)}] ${trimmed}`);
|
|
873
912
|
}
|
|
874
913
|
},
|
|
875
|
-
|
|
914
|
+
supportedDialogKinds: [...CLAUDE_SUPPORTED_DIALOG_KINDS],
|
|
915
|
+
onUserDialog: async (request, { signal }) => {
|
|
916
|
+
const dialog = parseClaudeAskUserDialog(request);
|
|
917
|
+
if (!dialog)
|
|
918
|
+
return { behavior: 'cancelled' };
|
|
919
|
+
const nativeRequestId = dialog.requestId ?? request.toolUseID ?? randomUUID();
|
|
920
|
+
const result = await requestClaudeUserDialog(dialog, nativeRequestId, signal);
|
|
921
|
+
return signal.aborted ? { behavior: 'cancelled' } : { behavior: 'completed', result };
|
|
922
|
+
},
|
|
923
|
+
canUseTool: async (toolName, input, options) => {
|
|
876
924
|
if (toolName === 'AskUserQuestion') {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
// Create + poll fold onto the shared spine. `responderPolicy: 'infer'`
|
|
882
|
-
// keeps the claude host's responder rule byte-identical (explicit
|
|
883
|
-
// `cardResponseUserId` wins, else omit — never the manager's ownerId
|
|
884
|
-
// fallback), and `native` carries the claude-specific descriptor.
|
|
885
|
-
const reply = await config.runtimeInputManager.request('input', conversationId, {
|
|
886
|
-
kind: 'clarify',
|
|
887
|
-
title: 'Claude needs input',
|
|
888
|
-
prompt: questions?.length
|
|
889
|
-
? 'Claude needs your input to continue.'
|
|
890
|
-
: 'Claude needs input to continue.',
|
|
891
|
-
...(questions ? { questions } : {}),
|
|
892
|
-
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
893
|
-
responseUserId: cardResponseUserId,
|
|
894
|
-
native: {
|
|
895
|
-
runtime: 'claude-code',
|
|
896
|
-
method: 'AskUserQuestion',
|
|
897
|
-
requestId: questionId,
|
|
898
|
-
turnId: session.currentTurnId ?? undefined,
|
|
899
|
-
},
|
|
900
|
-
turnId: session.currentTurnId ?? undefined,
|
|
901
|
-
}, {
|
|
902
|
-
requestId: questionId,
|
|
903
|
-
expiresAt,
|
|
904
|
-
responderPolicy: 'infer',
|
|
905
|
-
onCreated: () => {
|
|
906
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
907
|
-
session.lastActivity = Date.now();
|
|
908
|
-
},
|
|
909
|
-
});
|
|
910
|
-
if (reply.status !== 'submitted') {
|
|
911
|
-
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
912
|
-
}
|
|
913
|
-
return {
|
|
914
|
-
behavior: 'allow',
|
|
915
|
-
updatedInput: {
|
|
916
|
-
...input,
|
|
917
|
-
answers: reply.answers
|
|
918
|
-
? flattenRuntimeInputAnswers(reply.answers)
|
|
919
|
-
: reply.value
|
|
920
|
-
? { answer: reply.value }
|
|
921
|
-
: {},
|
|
922
|
-
},
|
|
923
|
-
};
|
|
924
|
-
}
|
|
925
|
-
catch {
|
|
926
|
-
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
927
|
-
}
|
|
925
|
+
const dialog = parseClaudeAskUserToolInput(input, options.toolUseID);
|
|
926
|
+
if (!dialog)
|
|
927
|
+
return buildClaudeAskUserPermissionDenied('Claude did not provide a valid question.');
|
|
928
|
+
return requestClaudeUserDialog(dialog, options.toolUseID, options.signal);
|
|
928
929
|
}
|
|
929
930
|
if (toolName === 'ExitPlanMode') {
|
|
930
931
|
try {
|
|
@@ -965,6 +966,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
965
966
|
...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
|
|
966
967
|
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
967
968
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
969
|
+
expiresAt: Date.now() + REPLY_TIMEOUT_MS,
|
|
968
970
|
});
|
|
969
971
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
|
|
970
972
|
const reply = await waitForReply('plan_approval_reply', planId);
|
|
@@ -1073,7 +1075,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1073
1075
|
environment,
|
|
1074
1076
|
query: q,
|
|
1075
1077
|
sendInput,
|
|
1076
|
-
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
|
|
1078
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, artifactRoutingMode),
|
|
1077
1079
|
state: {
|
|
1078
1080
|
model: undefined,
|
|
1079
1081
|
permissionMode: config.permissionMode,
|
|
@@ -1103,12 +1105,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1103
1105
|
lastAcceptedIntent: null,
|
|
1104
1106
|
lastActivity: Date.now(),
|
|
1105
1107
|
typingKeepaliveTimer: null,
|
|
1108
|
+
controlInterruptPending: false,
|
|
1106
1109
|
closed: false,
|
|
1107
1110
|
sdkSessionId: resumeSessionId || null,
|
|
1108
1111
|
pendingReply: null,
|
|
1109
1112
|
writeState: () => writeState(),
|
|
1110
1113
|
writeTurn: () => writeTurn(),
|
|
1111
1114
|
clearStreaming: () => clearStreaming(),
|
|
1115
|
+
markInputCompleted: (input) => markInputCompleted(input),
|
|
1112
1116
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1113
1117
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1114
1118
|
};
|
|
@@ -1153,6 +1157,24 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1153
1157
|
function clearStreaming() {
|
|
1154
1158
|
return streamingOutput.clear().catch(() => { });
|
|
1155
1159
|
}
|
|
1160
|
+
function markInputCompleted(input) {
|
|
1161
|
+
if (input?.kind !== 'canon' || !input.sourceMessageId)
|
|
1162
|
+
return;
|
|
1163
|
+
if (config.runtimeId) {
|
|
1164
|
+
try {
|
|
1165
|
+
saveRuntimeSessionState(config.runtimeId, {
|
|
1166
|
+
conversationId,
|
|
1167
|
+
baseCwd: config.recoveryBaseCwd ?? environment.baseCwd,
|
|
1168
|
+
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
1169
|
+
lastInboundMessageId: input.sourceMessageId,
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
catch (error) {
|
|
1173
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
config.onInputCompleted?.(input.sourceMessageId);
|
|
1177
|
+
}
|
|
1156
1178
|
function getFinalTurnTrail() {
|
|
1157
1179
|
const turnId = session.currentTurnId ?? `claude-${conversationId}`;
|
|
1158
1180
|
return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
|
|
@@ -1246,6 +1268,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1246
1268
|
},
|
|
1247
1269
|
});
|
|
1248
1270
|
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1271
|
+
markInputCompleted(deliverTurn);
|
|
1249
1272
|
session.state.state = 'idle';
|
|
1250
1273
|
session.pendingFinalDelivery = null;
|
|
1251
1274
|
clearFinalDeliveryTimer();
|
|
@@ -1458,7 +1481,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1458
1481
|
sendInput(input);
|
|
1459
1482
|
}
|
|
1460
1483
|
function drainPendingInput() {
|
|
1461
|
-
if (session.closed
|
|
1484
|
+
if (session.closed
|
|
1485
|
+
|| session.controlInterruptPending
|
|
1486
|
+
|| session.state.state === 'running'
|
|
1487
|
+
|| session.pendingInputs.length === 0)
|
|
1462
1488
|
return;
|
|
1463
1489
|
const next = session.pendingInputs.shift();
|
|
1464
1490
|
if (!next)
|
|
@@ -1467,7 +1493,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1467
1493
|
writeTurn();
|
|
1468
1494
|
void startCanonInput(next);
|
|
1469
1495
|
}
|
|
1470
|
-
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
1496
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
|
|
1471
1497
|
session.lastActivity = Date.now();
|
|
1472
1498
|
const input = createClaudeInputEnvelope({
|
|
1473
1499
|
kind: 'canon',
|
|
@@ -1476,6 +1502,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1476
1502
|
sourceMessageId,
|
|
1477
1503
|
markAccepted,
|
|
1478
1504
|
isOwnerTurn,
|
|
1505
|
+
requestingUserId,
|
|
1479
1506
|
artifactRoutingMode,
|
|
1480
1507
|
});
|
|
1481
1508
|
if (session.pendingFinalDelivery) {
|
|
@@ -1489,15 +1516,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1489
1516
|
return;
|
|
1490
1517
|
}
|
|
1491
1518
|
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
1492
|
-
if (session.activeInput) {
|
|
1493
|
-
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
1494
|
-
}
|
|
1495
1519
|
session.pendingInputs.unshift(input);
|
|
1496
|
-
session.turnState = 'interrupted';
|
|
1497
|
-
stopVisibleWorkSignal();
|
|
1498
|
-
clearStreaming().catch(() => { });
|
|
1499
1520
|
writeTurn();
|
|
1500
|
-
|
|
1521
|
+
const interruptedInput = session.activeInput;
|
|
1522
|
+
void session.query.interrupt().then(() => {
|
|
1523
|
+
if (interruptedInput) {
|
|
1524
|
+
session.interruptedTurnKeys.add(interruptedInput.turnKey);
|
|
1525
|
+
markInputCompleted(interruptedInput);
|
|
1526
|
+
}
|
|
1527
|
+
session.turnState = 'interrupted';
|
|
1528
|
+
stopVisibleWorkSignal();
|
|
1529
|
+
clearStreaming().catch(() => { });
|
|
1530
|
+
writeTurn();
|
|
1531
|
+
}).catch((error) => {
|
|
1532
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Input interrupt failed; message remains queued:`, error instanceof Error ? error.message : error);
|
|
1533
|
+
});
|
|
1501
1534
|
return;
|
|
1502
1535
|
}
|
|
1503
1536
|
session.lastAcceptedIntent = intent;
|
|
@@ -1644,6 +1677,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1644
1677
|
// Non-fatal
|
|
1645
1678
|
}
|
|
1646
1679
|
if (finalDelivered) {
|
|
1680
|
+
if (!finalText || !shouldDeliverFinal) {
|
|
1681
|
+
markInputCompleted(completedInput);
|
|
1682
|
+
}
|
|
1647
1683
|
if (completedInput?.turnKey === session.activeInput?.turnKey) {
|
|
1648
1684
|
session.activeInput = null;
|
|
1649
1685
|
}
|
|
@@ -1722,9 +1758,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1722
1758
|
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1723
1759
|
try {
|
|
1724
1760
|
const models = await q.supportedModels();
|
|
1725
|
-
const
|
|
1726
|
-
|
|
1727
|
-
|
|
1761
|
+
const supplemental = await detectSupplementalRuntimeModels(cwd);
|
|
1762
|
+
const modelList = resolveClaudeModelOptions({
|
|
1763
|
+
discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
|
|
1728
1764
|
selectedModel: session.state.model,
|
|
1729
1765
|
});
|
|
1730
1766
|
session.availableModels = modelList;
|
|
@@ -2192,25 +2228,13 @@ export async function main() {
|
|
|
2192
2228
|
});
|
|
2193
2229
|
}
|
|
2194
2230
|
else {
|
|
2195
|
-
const existingState = loadRuntimeSessionState(runtimeId, {
|
|
2196
|
-
conversationId,
|
|
2197
|
-
baseCwd: session.environment.baseCwd,
|
|
2198
|
-
executionMode: session.environment.mode,
|
|
2199
|
-
});
|
|
2200
2231
|
savedSessionIds.delete(resumeKey);
|
|
2201
2232
|
clearRuntimeSessionState(runtimeId, {
|
|
2202
2233
|
conversationId,
|
|
2203
2234
|
baseCwd: session.environment.baseCwd,
|
|
2204
2235
|
executionMode: session.environment.mode,
|
|
2236
|
+
preserveInboundCursor: true,
|
|
2205
2237
|
});
|
|
2206
|
-
if (existingState?.lastInboundMessageId) {
|
|
2207
|
-
saveRuntimeSessionState(runtimeId, {
|
|
2208
|
-
conversationId,
|
|
2209
|
-
baseCwd: session.environment.baseCwd,
|
|
2210
|
-
executionMode: session.environment.mode,
|
|
2211
|
-
lastInboundMessageId: existingState.lastInboundMessageId,
|
|
2212
|
-
});
|
|
2213
|
-
}
|
|
2214
2238
|
}
|
|
2215
2239
|
session.clearStreaming().catch(() => { });
|
|
2216
2240
|
if (session.idleResetTimer) {
|
|
@@ -2234,8 +2258,27 @@ export async function main() {
|
|
|
2234
2258
|
}
|
|
2235
2259
|
async function resetRuntimeSession(session) {
|
|
2236
2260
|
const conversationId = session.conversationId;
|
|
2261
|
+
const sdkTurnActive = (session.state.state === 'running'
|
|
2262
|
+
|| session.state.state === 'requires_action') && !session.pendingFinalDelivery;
|
|
2263
|
+
session.controlInterruptPending = sdkTurnActive;
|
|
2264
|
+
const interruptResult = await confirmClaudeInterrupt({
|
|
2265
|
+
active: sdkTurnActive,
|
|
2266
|
+
interrupt: () => session.query.interrupt(),
|
|
2267
|
+
});
|
|
2268
|
+
if (interruptResult === 'defer') {
|
|
2269
|
+
session.controlInterruptPending = false;
|
|
2270
|
+
throw new Error('Claude Code did not acknowledge the new-session interrupt.');
|
|
2271
|
+
}
|
|
2272
|
+
session.closed = true;
|
|
2273
|
+
if (session.finalDeliveryTimer) {
|
|
2274
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
2275
|
+
session.finalDeliveryTimer = null;
|
|
2276
|
+
}
|
|
2237
2277
|
const droppedInputs = session.pendingInputs.splice(0);
|
|
2238
2278
|
await markSessionPendingInputsRejected(conversationId, droppedInputs);
|
|
2279
|
+
session.markInputCompleted(session.activeInput);
|
|
2280
|
+
for (const input of droppedInputs)
|
|
2281
|
+
session.markInputCompleted(input);
|
|
2239
2282
|
if (session.activeInput) {
|
|
2240
2283
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2241
2284
|
}
|
|
@@ -2244,9 +2287,6 @@ export async function main() {
|
|
|
2244
2287
|
session.turnState = 'interrupted';
|
|
2245
2288
|
session.clearStreaming().catch(() => { });
|
|
2246
2289
|
typingSignals.clear(conversationId).catch(() => { });
|
|
2247
|
-
if (session.state.state === 'running') {
|
|
2248
|
-
await session.query.interrupt().catch(() => { });
|
|
2249
|
-
}
|
|
2250
2290
|
closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
|
|
2251
2291
|
}
|
|
2252
2292
|
async function markSessionPendingInputsRejected(conversationId, inputs) {
|
|
@@ -2330,10 +2370,17 @@ export async function main() {
|
|
|
2330
2370
|
conversationId: convoId,
|
|
2331
2371
|
baseCwd: existing.environment.baseCwd,
|
|
2332
2372
|
executionMode: existing.environment.mode,
|
|
2373
|
+
preserveInboundCursor: true,
|
|
2333
2374
|
});
|
|
2334
2375
|
}
|
|
2335
2376
|
}
|
|
2336
2377
|
existing?.clearStreaming().catch(() => { });
|
|
2378
|
+
if (existing) {
|
|
2379
|
+
settleInboundMessageId(existing.activeInput?.sourceMessageId, false);
|
|
2380
|
+
for (const input of existing.pendingInputs) {
|
|
2381
|
+
settleInboundMessageId(input.sourceMessageId, false);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2337
2384
|
typingSignals.clear(convoId).catch(() => { });
|
|
2338
2385
|
typingSignals.dispose(convoId);
|
|
2339
2386
|
sessions.delete(convoId);
|
|
@@ -2354,7 +2401,11 @@ export async function main() {
|
|
|
2354
2401
|
runtimeInputManager,
|
|
2355
2402
|
canRequestApproval: () => canRequestCanonApproval(conversationId),
|
|
2356
2403
|
runtimeId,
|
|
2404
|
+
recoveryBaseCwd: workingDir,
|
|
2357
2405
|
ownerId,
|
|
2406
|
+
onInputCompleted: (sourceMessageId) => {
|
|
2407
|
+
settleInboundMessageId(sourceMessageId, true);
|
|
2408
|
+
},
|
|
2358
2409
|
publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
|
|
2359
2410
|
}, resumeId);
|
|
2360
2411
|
sessions.set(conversationId, session);
|
|
@@ -2375,9 +2426,27 @@ export async function main() {
|
|
|
2375
2426
|
}
|
|
2376
2427
|
}
|
|
2377
2428
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2378
|
-
const m =
|
|
2429
|
+
const m = {
|
|
2430
|
+
id: input.message.id,
|
|
2431
|
+
senderId: input.message.senderId,
|
|
2432
|
+
senderName: input.message.senderName,
|
|
2433
|
+
senderType: input.message.senderType,
|
|
2434
|
+
isOwner: input.message.isOwner,
|
|
2435
|
+
text: input.message.text ?? undefined,
|
|
2436
|
+
contentType: input.message.contentType,
|
|
2437
|
+
attachments: input.message.attachments,
|
|
2438
|
+
mentions: input.message.mentions,
|
|
2439
|
+
reactions: input.message.reactions,
|
|
2440
|
+
replyTo: input.message.replyTo ?? undefined,
|
|
2441
|
+
replyToPosition: input.message.replyToPosition ?? undefined,
|
|
2442
|
+
forwarded: input.message.forwarded,
|
|
2443
|
+
forwardedFrom: input.message.forwardedFrom,
|
|
2444
|
+
metadata: input.message.metadata,
|
|
2445
|
+
contactCard: input.message.contactCard,
|
|
2446
|
+
createdAt: input.message.createdAt,
|
|
2447
|
+
};
|
|
2379
2448
|
if (m.senderId === agentId)
|
|
2380
|
-
return;
|
|
2449
|
+
return 'handled';
|
|
2381
2450
|
let materialized = [];
|
|
2382
2451
|
if (m.id) {
|
|
2383
2452
|
try {
|
|
@@ -2414,7 +2483,7 @@ export async function main() {
|
|
|
2414
2483
|
const autoReply = decideAutoReply(participantContext, behavior);
|
|
2415
2484
|
if (!autoReply.allow) {
|
|
2416
2485
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
|
|
2417
|
-
return;
|
|
2486
|
+
return 'handled';
|
|
2418
2487
|
}
|
|
2419
2488
|
markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
|
|
2420
2489
|
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
@@ -2437,7 +2506,7 @@ export async function main() {
|
|
|
2437
2506
|
replyBehavior: 'suppress_auto_reply',
|
|
2438
2507
|
},
|
|
2439
2508
|
}).catch(() => { });
|
|
2440
|
-
return;
|
|
2509
|
+
return 'handled';
|
|
2441
2510
|
}
|
|
2442
2511
|
session.activeSelfContextId = activeSelfContextId;
|
|
2443
2512
|
const promptText = buildCanonPrompt({
|
|
@@ -2463,15 +2532,154 @@ export async function main() {
|
|
|
2463
2532
|
content: messageContent,
|
|
2464
2533
|
},
|
|
2465
2534
|
parent_tool_use_id: null,
|
|
2466
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2535
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2536
|
+
return 'queued';
|
|
2537
|
+
}
|
|
2538
|
+
const acceptedInboundMessageIds = new Set();
|
|
2539
|
+
const inFlightInboundMessageIds = new Set();
|
|
2540
|
+
function claimInboundMessageId(messageId) {
|
|
2541
|
+
if (!messageId)
|
|
2542
|
+
return true;
|
|
2543
|
+
if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
|
|
2544
|
+
return false;
|
|
2545
|
+
inFlightInboundMessageIds.add(messageId);
|
|
2546
|
+
return true;
|
|
2547
|
+
}
|
|
2548
|
+
function settleInboundMessageId(messageId, accepted) {
|
|
2549
|
+
if (!messageId)
|
|
2550
|
+
return;
|
|
2551
|
+
inFlightInboundMessageIds.delete(messageId);
|
|
2552
|
+
if (!accepted)
|
|
2553
|
+
return;
|
|
2554
|
+
acceptedInboundMessageIds.add(messageId);
|
|
2555
|
+
while (acceptedInboundMessageIds.size > 2_048) {
|
|
2556
|
+
const oldest = acceptedInboundMessageIds.values().next().value;
|
|
2557
|
+
if (!oldest)
|
|
2558
|
+
break;
|
|
2559
|
+
acceptedInboundMessageIds.delete(oldest);
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
function handleRuntimeReplyMessage(conversationId, message) {
|
|
2563
|
+
if (message.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2564
|
+
const consumed = approvalManager?.handleMessage(conversationId, {
|
|
2565
|
+
senderId: message.senderId,
|
|
2566
|
+
metadata: message.metadata,
|
|
2567
|
+
}) ?? false;
|
|
2568
|
+
if (message.metadata?.type === 'approval_reply') {
|
|
2569
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${message.senderName || message.senderId}`);
|
|
2570
|
+
return true;
|
|
2571
|
+
}
|
|
2572
|
+
if (consumed) {
|
|
2573
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Approval text reply consumed from ${message.senderName || message.senderId}`);
|
|
2574
|
+
return true;
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
if (message.metadata?.type !== 'plan_approval_reply')
|
|
2578
|
+
return false;
|
|
2579
|
+
const existingSession = sessions.get(conversationId);
|
|
2580
|
+
const pendingReply = existingSession?.pendingReply;
|
|
2581
|
+
const isOwnerReply = message.isOwner ?? (ownerId != null && message.senderId === ownerId);
|
|
2582
|
+
const replyRequestId = message.metadata.planId;
|
|
2583
|
+
if (!pendingReply) {
|
|
2584
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring stale ${message.metadata.type} with no pending request`);
|
|
2585
|
+
return true;
|
|
2586
|
+
}
|
|
2587
|
+
if (!isOwnerReply) {
|
|
2588
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring ${message.metadata.type} from non-owner ${message.senderId}`);
|
|
2589
|
+
return true;
|
|
2590
|
+
}
|
|
2591
|
+
if (pendingReply.type !== message.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2592
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring mismatched ${message.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2593
|
+
return true;
|
|
2473
2594
|
}
|
|
2595
|
+
existingSession.pendingReply = null;
|
|
2596
|
+
existingSession.lastActivity = Date.now();
|
|
2597
|
+
pendingReply.resolve(message.metadata);
|
|
2598
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Reply received (${message.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2599
|
+
return true;
|
|
2474
2600
|
}
|
|
2601
|
+
function persistedConversationCursors(conversationId) {
|
|
2602
|
+
const canonicalCursor = loadRuntimeSessionState(runtimeId, {
|
|
2603
|
+
conversationId,
|
|
2604
|
+
baseCwd: workingDir,
|
|
2605
|
+
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
2606
|
+
})?.lastInboundMessageId ?? null;
|
|
2607
|
+
if (canonicalCursor)
|
|
2608
|
+
return { canonicalCursor, legacyCursors: [] };
|
|
2609
|
+
const legacyCursors = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
|
|
2610
|
+
.filter((state) => state.conversationId === conversationId)
|
|
2611
|
+
.map((state) => state.lastInboundMessageId)
|
|
2612
|
+
.filter((messageId) => Boolean(messageId));
|
|
2613
|
+
return { canonicalCursor: null, legacyCursors: [...new Set(legacyCursors)] };
|
|
2614
|
+
}
|
|
2615
|
+
async function performMissedInboundRecovery() {
|
|
2616
|
+
for (const conversationId of knownConversationIds) {
|
|
2617
|
+
try {
|
|
2618
|
+
const cursors = persistedConversationCursors(conversationId);
|
|
2619
|
+
const recovered = await collectMissedInboundMessages({
|
|
2620
|
+
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
2621
|
+
cursor: cursors.canonicalCursor,
|
|
2622
|
+
cursorCandidates: cursors.legacyCursors,
|
|
2623
|
+
agentId,
|
|
2624
|
+
});
|
|
2625
|
+
if (!cursors.canonicalCursor
|
|
2626
|
+
&& recovered.cursor
|
|
2627
|
+
&& recovered.messages.length === 0) {
|
|
2628
|
+
saveRuntimeSessionState(runtimeId, {
|
|
2629
|
+
conversationId,
|
|
2630
|
+
baseCwd: workingDir,
|
|
2631
|
+
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
2632
|
+
lastInboundMessageId: recovered.cursor,
|
|
2633
|
+
});
|
|
2634
|
+
}
|
|
2635
|
+
if (recovered.mode === 'truncated-window') {
|
|
2636
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
|
|
2637
|
+
}
|
|
2638
|
+
for (const message of recovered.messages) {
|
|
2639
|
+
if (!claimInboundMessageId(message.id))
|
|
2640
|
+
continue;
|
|
2641
|
+
try {
|
|
2642
|
+
let queued = false;
|
|
2643
|
+
if (!handleRuntimeReplyMessage(conversationId, message)) {
|
|
2644
|
+
const trigger = shouldTriggerAgentTurn({
|
|
2645
|
+
senderType: message.senderType,
|
|
2646
|
+
metadata: message.metadata,
|
|
2647
|
+
});
|
|
2648
|
+
if (trigger.allow) {
|
|
2649
|
+
queued = await enqueueRecoveredInboundMessage({
|
|
2650
|
+
conversationId,
|
|
2651
|
+
message,
|
|
2652
|
+
hydratedPage: recovered.newestPage,
|
|
2653
|
+
}) === 'queued';
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
if (!queued)
|
|
2657
|
+
settleInboundMessageId(message.id, true);
|
|
2658
|
+
}
|
|
2659
|
+
catch (error) {
|
|
2660
|
+
settleInboundMessageId(message.id, false);
|
|
2661
|
+
throw error;
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
if (recovered.messages.length > 0) {
|
|
2665
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
|
|
2666
|
+
}
|
|
2667
|
+
}
|
|
2668
|
+
catch (error) {
|
|
2669
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
let recoveryInFlight = null;
|
|
2674
|
+
function recoverMissedInboundMessages() {
|
|
2675
|
+
if (recoveryInFlight)
|
|
2676
|
+
return recoveryInFlight;
|
|
2677
|
+
recoveryInFlight = performMissedInboundRecovery().finally(() => {
|
|
2678
|
+
recoveryInFlight = null;
|
|
2679
|
+
});
|
|
2680
|
+
return recoveryInFlight;
|
|
2681
|
+
}
|
|
2682
|
+
await recoverMissedInboundMessages();
|
|
2475
2683
|
// ── Connect SSE stream for inbound Canon messages ──
|
|
2476
2684
|
const stream = new CanonStream({
|
|
2477
2685
|
apiKey,
|
|
@@ -2481,53 +2689,19 @@ export async function main() {
|
|
|
2481
2689
|
const m = payload.message;
|
|
2482
2690
|
if (m.senderId === agentId)
|
|
2483
2691
|
return;
|
|
2692
|
+
if (!claimInboundMessageId(m.id))
|
|
2693
|
+
return;
|
|
2484
2694
|
const convoId = payload.conversationId;
|
|
2485
2695
|
knownConversationIds.add(convoId);
|
|
2486
|
-
if (
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
metadata: m.metadata,
|
|
2490
|
-
}) ?? false;
|
|
2491
|
-
if (m.metadata?.type === 'approval_reply') {
|
|
2492
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2493
|
-
return;
|
|
2494
|
-
}
|
|
2495
|
-
if (consumed) {
|
|
2496
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2497
|
-
return;
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
// Check for metadata replies (plan approvals).
|
|
2501
|
-
if (m.metadata?.type === 'plan_approval_reply') {
|
|
2502
|
-
const existingSession = sessions.get(convoId);
|
|
2503
|
-
const pendingReply = existingSession?.pendingReply;
|
|
2504
|
-
const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2505
|
-
const replyRequestId = m.metadata.planId;
|
|
2506
|
-
if (!pendingReply) {
|
|
2507
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
|
|
2508
|
-
return;
|
|
2509
|
-
}
|
|
2510
|
-
if (!isOwnerReply) {
|
|
2511
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
|
|
2512
|
-
return;
|
|
2513
|
-
}
|
|
2514
|
-
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2515
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2516
|
-
return;
|
|
2517
|
-
}
|
|
2518
|
-
if (existingSession?.pendingReply) {
|
|
2519
|
-
const { resolve } = existingSession.pendingReply;
|
|
2520
|
-
existingSession.pendingReply = null;
|
|
2521
|
-
existingSession.lastActivity = Date.now();
|
|
2522
|
-
resolve(m.metadata);
|
|
2523
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2524
|
-
return;
|
|
2525
|
-
}
|
|
2696
|
+
if (handleRuntimeReplyMessage(convoId, m)) {
|
|
2697
|
+
settleInboundMessageId(m.id, true);
|
|
2698
|
+
return;
|
|
2526
2699
|
}
|
|
2527
2700
|
const sender = m.senderName || m.senderId;
|
|
2528
2701
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2529
2702
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2530
2703
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2704
|
+
settleInboundMessageId(m.id, true);
|
|
2531
2705
|
return;
|
|
2532
2706
|
}
|
|
2533
2707
|
void (async () => {
|
|
@@ -2572,7 +2746,7 @@ export async function main() {
|
|
|
2572
2746
|
: decideAutoReply(participantContext, behavior);
|
|
2573
2747
|
if (!autoReply.allow) {
|
|
2574
2748
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2575
|
-
return;
|
|
2749
|
+
return false;
|
|
2576
2750
|
}
|
|
2577
2751
|
markGroupContextModeUsed(convoId, participantContext.groupContextMode);
|
|
2578
2752
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
@@ -2596,7 +2770,7 @@ export async function main() {
|
|
|
2596
2770
|
replyBehavior: 'suppress_auto_reply',
|
|
2597
2771
|
},
|
|
2598
2772
|
}).catch(() => { });
|
|
2599
|
-
return;
|
|
2773
|
+
return false;
|
|
2600
2774
|
}
|
|
2601
2775
|
session.activeSelfContextId = activeSelfContextId;
|
|
2602
2776
|
const promptText = buildCanonPrompt({
|
|
@@ -2622,15 +2796,15 @@ export async function main() {
|
|
|
2622
2796
|
content: messageContent,
|
|
2623
2797
|
},
|
|
2624
2798
|
parent_tool_use_id: null,
|
|
2625
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
}
|
|
2633
|
-
})
|
|
2799
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2800
|
+
return true;
|
|
2801
|
+
})().then((queued) => {
|
|
2802
|
+
if (!queued)
|
|
2803
|
+
settleInboundMessageId(m.id, true);
|
|
2804
|
+
}).catch((error) => {
|
|
2805
|
+
settleInboundMessageId(m.id, false);
|
|
2806
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
|
|
2807
|
+
});
|
|
2634
2808
|
},
|
|
2635
2809
|
onMessageDeleted: (payload) => {
|
|
2636
2810
|
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
@@ -2641,6 +2815,7 @@ export async function main() {
|
|
|
2641
2815
|
onConnected: () => {
|
|
2642
2816
|
streamConnected = true;
|
|
2643
2817
|
void publishRuntimeHeartbeat();
|
|
2818
|
+
void recoverMissedInboundMessages();
|
|
2644
2819
|
console.error('[canon-host] SSE connected');
|
|
2645
2820
|
},
|
|
2646
2821
|
onDisconnected: () => {
|
|
@@ -2687,31 +2862,57 @@ export async function main() {
|
|
|
2687
2862
|
});
|
|
2688
2863
|
session.writeState();
|
|
2689
2864
|
}
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
// signal is reported but never retried.
|
|
2865
|
+
// Stop/new-session signals remain pending until the Claude SDK confirms the
|
|
2866
|
+
// interrupt. `defer` deliberately makes the poller revisit the same node.
|
|
2693
2867
|
async function handleRuntimeControlSignal(event) {
|
|
2694
2868
|
const convoId = event.conversationId;
|
|
2695
2869
|
const session = sessions.get(convoId);
|
|
2696
2870
|
if (!session || session.closed)
|
|
2697
|
-
return {
|
|
2871
|
+
return { defer: true };
|
|
2698
2872
|
if (event.type === 'new_session') {
|
|
2699
2873
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2700
|
-
|
|
2874
|
+
try {
|
|
2875
|
+
await resetRuntimeSession(session);
|
|
2876
|
+
}
|
|
2877
|
+
catch (error) {
|
|
2878
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] New-session interrupt failed; retrying signal:`, error instanceof Error ? error.message : error);
|
|
2879
|
+
return { defer: true };
|
|
2880
|
+
}
|
|
2701
2881
|
return;
|
|
2702
2882
|
}
|
|
2703
|
-
const isActive = session.state.state === 'running';
|
|
2883
|
+
const isActive = session.state.state === 'running' || session.state.state === 'requires_action';
|
|
2704
2884
|
if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2705
2885
|
return; // Nothing to interrupt — consume the stale signal
|
|
2706
2886
|
}
|
|
2707
2887
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2708
|
-
|
|
2709
|
-
|
|
2888
|
+
const sdkTurnActive = isActive && !session.pendingFinalDelivery;
|
|
2889
|
+
session.controlInterruptPending = sdkTurnActive;
|
|
2890
|
+
const interruptResult = await confirmClaudeInterrupt({
|
|
2891
|
+
active: sdkTurnActive,
|
|
2892
|
+
interrupt: () => session.query.interrupt(),
|
|
2893
|
+
});
|
|
2894
|
+
if (interruptResult === 'defer') {
|
|
2895
|
+
session.controlInterruptPending = false;
|
|
2896
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Stop interrupt failed; retrying signal`);
|
|
2897
|
+
return { defer: true };
|
|
2898
|
+
}
|
|
2899
|
+
const droppedInputs = event.type === 'stop_and_drop'
|
|
2900
|
+
? session.pendingInputs.splice(0)
|
|
2901
|
+
: [];
|
|
2902
|
+
if (droppedInputs.length > 0) {
|
|
2710
2903
|
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2711
2904
|
}
|
|
2905
|
+
session.markInputCompleted(session.activeInput);
|
|
2906
|
+
for (const input of droppedInputs)
|
|
2907
|
+
session.markInputCompleted(input);
|
|
2712
2908
|
if (session.activeInput) {
|
|
2713
2909
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2714
2910
|
}
|
|
2911
|
+
if (session.finalDeliveryTimer) {
|
|
2912
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
2913
|
+
session.finalDeliveryTimer = null;
|
|
2914
|
+
}
|
|
2915
|
+
session.pendingFinalDelivery = null;
|
|
2715
2916
|
session.pendingFinalText = null;
|
|
2716
2917
|
session.turnState = 'interrupted';
|
|
2717
2918
|
session.clearStreaming().catch(() => { });
|
|
@@ -2726,9 +2927,7 @@ export async function main() {
|
|
|
2726
2927
|
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2727
2928
|
completedAt: { '.sv': 'timestamp' },
|
|
2728
2929
|
}).catch(() => { });
|
|
2729
|
-
|
|
2730
|
-
await session.query.interrupt();
|
|
2731
|
-
}
|
|
2930
|
+
session.controlInterruptPending = false;
|
|
2732
2931
|
}
|
|
2733
2932
|
controlPoller.start();
|
|
2734
2933
|
// ── Heartbeat (per-session) ──
|