@canonmsg/claude-code-plugin 0.25.0 → 0.25.3
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 +426 -243
- package/dist/session-state.d.ts +21 -3
- package/dist/session-state.js +77 -16
- package/dist/startup-recovery.d.ts +6 -3
- package/dist/startup-recovery.js +45 -14
- package/dist/tool-policy.d.ts +3 -1
- package/dist/tool-policy.js +11 -3
- package/dist/user-dialog.d.ts +19 -0
- package/dist/user-dialog.js +151 -0
- package/package.json +4 -4
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, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, } from './session-state.js';
|
|
37
|
+
import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } 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,
|
|
@@ -768,7 +738,6 @@ async function buildCanonUserContent(input) {
|
|
|
768
738
|
// ── Session factory ─────────────────────────────────────────────────
|
|
769
739
|
function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
770
740
|
const { cwd } = environment;
|
|
771
|
-
const cardResponseUserId = config.ownerId ?? undefined;
|
|
772
741
|
// Per-session input stream with message queue
|
|
773
742
|
let resolveInput = null;
|
|
774
743
|
let inputDone = false;
|
|
@@ -784,7 +753,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
784
753
|
messageQueue.push(input);
|
|
785
754
|
}
|
|
786
755
|
};
|
|
787
|
-
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
756
|
+
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
|
|
788
757
|
sendInput(createClaudeInputEnvelope({
|
|
789
758
|
kind: 'canon',
|
|
790
759
|
msg,
|
|
@@ -792,6 +761,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
792
761
|
sourceMessageId,
|
|
793
762
|
markAccepted,
|
|
794
763
|
isOwnerTurn,
|
|
764
|
+
requestingUserId,
|
|
795
765
|
artifactRoutingMode,
|
|
796
766
|
}));
|
|
797
767
|
};
|
|
@@ -825,7 +795,53 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
825
795
|
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
826
796
|
let session;
|
|
827
797
|
const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
828
|
-
|
|
798
|
+
const coordinateUserDialog = createClaudeUserDialogCoordinator();
|
|
799
|
+
async function requestClaudeUserDialog(dialog, nativeRequestId, signal) {
|
|
800
|
+
const questionId = /^[A-Za-z0-9_.:-]{1,160}$/.test(nativeRequestId)
|
|
801
|
+
? nativeRequestId
|
|
802
|
+
: randomUUID();
|
|
803
|
+
const responseRouting = resolveClaudeTurnResponseRouting(session.activeInput, config.ownerId, dialog.questions.some((question) => question.isSecret));
|
|
804
|
+
const responseUserId = responseRouting.responseUserId;
|
|
805
|
+
if (!responseUserId) {
|
|
806
|
+
return buildClaudeAskUserPermissionDenied('No eligible human participant can answer this question.');
|
|
807
|
+
}
|
|
808
|
+
return coordinateUserDialog(questionId, async () => {
|
|
809
|
+
try {
|
|
810
|
+
const reply = await config.runtimeInputManager.request('input', conversationId, {
|
|
811
|
+
kind: 'clarify',
|
|
812
|
+
title: 'Claude needs input',
|
|
813
|
+
prompt: 'Claude needs your input to continue.',
|
|
814
|
+
questions: dialog.questions,
|
|
815
|
+
sensitive: dialog.questions.some((question) => question.isSecret),
|
|
816
|
+
responseUserId,
|
|
817
|
+
native: {
|
|
818
|
+
runtime: 'claude-code',
|
|
819
|
+
method: 'AskUserQuestion',
|
|
820
|
+
requestId: nativeRequestId,
|
|
821
|
+
turnId: session.currentTurnId ?? undefined,
|
|
822
|
+
},
|
|
823
|
+
turnId: session.currentTurnId ?? undefined,
|
|
824
|
+
}, {
|
|
825
|
+
requestId: questionId,
|
|
826
|
+
expiresAt: Date.now() + REPLY_TIMEOUT_MS,
|
|
827
|
+
responderPolicy: 'infer',
|
|
828
|
+
signal,
|
|
829
|
+
onCreated: () => {
|
|
830
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
831
|
+
session.lastActivity = Date.now();
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
if (reply.status === 'submitted') {
|
|
835
|
+
return buildClaudeAskUserPermissionResult(dialog, reply);
|
|
836
|
+
}
|
|
837
|
+
return buildClaudeAskUserPermissionDenied(reply.status === 'timeout' ? 'No reply received (timed out)' : 'The user cancelled this question.');
|
|
838
|
+
}
|
|
839
|
+
catch {
|
|
840
|
+
return buildClaudeAskUserPermissionDenied(signal.aborted ? 'The question was interrupted.' : 'No reply received (timed out)');
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
function waitForReply(type, requestId, responseUserId) {
|
|
829
845
|
// Keep session alive while waiting for user input
|
|
830
846
|
session.lastActivity = Date.now();
|
|
831
847
|
return new Promise((resolve, reject) => {
|
|
@@ -836,6 +852,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
836
852
|
session.pendingReply = {
|
|
837
853
|
type,
|
|
838
854
|
requestId,
|
|
855
|
+
responseUserId,
|
|
839
856
|
resolve: (metadata) => {
|
|
840
857
|
clearTimeout(timer);
|
|
841
858
|
resolve(metadata);
|
|
@@ -857,6 +874,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
857
874
|
const queryOptions = {
|
|
858
875
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
859
876
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
877
|
+
model: 'default',
|
|
860
878
|
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
861
879
|
allowDangerouslySkipPermissions: true,
|
|
862
880
|
cwd,
|
|
@@ -872,61 +890,30 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
872
890
|
console.error(`[claude-cli] [${conversationId.slice(0, 8)}] ${trimmed}`);
|
|
873
891
|
}
|
|
874
892
|
},
|
|
875
|
-
|
|
893
|
+
supportedDialogKinds: [...CLAUDE_SUPPORTED_DIALOG_KINDS],
|
|
894
|
+
onUserDialog: async (request, { signal }) => {
|
|
895
|
+
const dialog = parseClaudeAskUserDialog(request);
|
|
896
|
+
if (!dialog)
|
|
897
|
+
return { behavior: 'cancelled' };
|
|
898
|
+
const nativeRequestId = resolveClaudeUserDialogRequestId(request, dialog) ?? randomUUID();
|
|
899
|
+
const result = await requestClaudeUserDialog(dialog, nativeRequestId, signal);
|
|
900
|
+
return signal.aborted ? { behavior: 'cancelled' } : { behavior: 'completed', result };
|
|
901
|
+
},
|
|
902
|
+
canUseTool: async (toolName, input, options) => {
|
|
876
903
|
if (toolName === 'AskUserQuestion') {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
}
|
|
904
|
+
const dialog = parseClaudeAskUserToolInput(input, options.toolUseID);
|
|
905
|
+
if (!dialog)
|
|
906
|
+
return buildClaudeAskUserPermissionDenied('Claude did not provide a valid question.');
|
|
907
|
+
return requestClaudeUserDialog(dialog, options.toolUseID, options.signal);
|
|
908
|
+
}
|
|
909
|
+
const turnResponseRouting = resolveClaudeTurnResponseRouting(session.activeInput, config.ownerId);
|
|
910
|
+
if (toolName === 'ExitPlanMode') {
|
|
911
|
+
if (!turnResponseRouting.responseUserId) {
|
|
913
912
|
return {
|
|
914
|
-
behavior: '
|
|
915
|
-
|
|
916
|
-
...input,
|
|
917
|
-
answers: reply.answers
|
|
918
|
-
? flattenRuntimeInputAnswers(reply.answers)
|
|
919
|
-
: reply.value
|
|
920
|
-
? { answer: reply.value }
|
|
921
|
-
: {},
|
|
922
|
-
},
|
|
913
|
+
behavior: 'deny',
|
|
914
|
+
message: 'No eligible human participant can review this plan.',
|
|
923
915
|
};
|
|
924
916
|
}
|
|
925
|
-
catch {
|
|
926
|
-
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
if (toolName === 'ExitPlanMode') {
|
|
930
917
|
try {
|
|
931
918
|
const planId = randomUUID();
|
|
932
919
|
const allowedPrompts = Array.isArray(input.allowedPrompts)
|
|
@@ -962,21 +949,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
962
949
|
title: 'Plan ready for review.',
|
|
963
950
|
summary: 'Claude has prepared a plan and is asking permission to continue.',
|
|
964
951
|
...(planBody ? { body: planBody } : {}),
|
|
965
|
-
|
|
952
|
+
responseUserId: turnResponseRouting.responseUserId,
|
|
966
953
|
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
967
954
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
955
|
+
expiresAt: Date.now() + REPLY_TIMEOUT_MS,
|
|
968
956
|
});
|
|
969
957
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
|
|
970
|
-
const reply = await waitForReply('plan_approval_reply', planId);
|
|
958
|
+
const reply = await waitForReply('plan_approval_reply', planId, turnResponseRouting.responseUserId);
|
|
971
959
|
if (reply.decision === 'approve') {
|
|
972
960
|
// Honor opt-in selective grants: when the card sent an explicit
|
|
973
961
|
// granted subset, pre-approve those tools as session rules so they
|
|
974
962
|
// do not re-prompt. Owner-turn only; absent grants preserve the
|
|
975
963
|
// legacy behavior (each tool still prompts individually).
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
if (isOwnerTurn && config.approvalManager && Array.isArray(reply.grantedPrompts)) {
|
|
964
|
+
if (turnResponseRouting.allowSessionRule
|
|
965
|
+
&& config.approvalManager
|
|
966
|
+
&& Array.isArray(reply.grantedPrompts)) {
|
|
980
967
|
// Only pre-approve tools the plan actually offered, and bound the
|
|
981
968
|
// count/length to match parseSessionRule's caps — grantedPrompts
|
|
982
969
|
// is untrusted wire metadata, not the validated approval path.
|
|
@@ -1036,9 +1023,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1036
1023
|
conversationId,
|
|
1037
1024
|
runtimeId: config.runtimeId,
|
|
1038
1025
|
turnId: session.currentTurnId,
|
|
1039
|
-
isOwnerTurn:
|
|
1040
|
-
|
|
1041
|
-
|
|
1026
|
+
isOwnerTurn: turnResponseRouting.isOwnerTurn,
|
|
1027
|
+
responseUserId: turnResponseRouting.responseUserId,
|
|
1028
|
+
allowSessionRule: turnResponseRouting.allowSessionRule,
|
|
1042
1029
|
approvalManager: config.approvalManager,
|
|
1043
1030
|
canRequestApproval: config.canRequestApproval,
|
|
1044
1031
|
allowedNonOwnerTools: allowedNonOwnerClaudeTools,
|
|
@@ -1073,7 +1060,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1073
1060
|
environment,
|
|
1074
1061
|
query: q,
|
|
1075
1062
|
sendInput,
|
|
1076
|
-
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, artifactRoutingMode),
|
|
1063
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, artifactRoutingMode),
|
|
1077
1064
|
state: {
|
|
1078
1065
|
model: undefined,
|
|
1079
1066
|
permissionMode: config.permissionMode,
|
|
@@ -1103,12 +1090,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1103
1090
|
lastAcceptedIntent: null,
|
|
1104
1091
|
lastActivity: Date.now(),
|
|
1105
1092
|
typingKeepaliveTimer: null,
|
|
1093
|
+
controlInterruptPending: false,
|
|
1106
1094
|
closed: false,
|
|
1107
1095
|
sdkSessionId: resumeSessionId || null,
|
|
1108
1096
|
pendingReply: null,
|
|
1109
1097
|
writeState: () => writeState(),
|
|
1110
1098
|
writeTurn: () => writeTurn(),
|
|
1111
1099
|
clearStreaming: () => clearStreaming(),
|
|
1100
|
+
markInputCompleted: (input) => markInputCompleted(input),
|
|
1112
1101
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1113
1102
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1114
1103
|
};
|
|
@@ -1153,6 +1142,24 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1153
1142
|
function clearStreaming() {
|
|
1154
1143
|
return streamingOutput.clear().catch(() => { });
|
|
1155
1144
|
}
|
|
1145
|
+
function markInputCompleted(input) {
|
|
1146
|
+
if (input?.kind !== 'canon' || !input.sourceMessageId)
|
|
1147
|
+
return;
|
|
1148
|
+
if (config.runtimeId) {
|
|
1149
|
+
try {
|
|
1150
|
+
saveRuntimeSessionState(config.runtimeId, {
|
|
1151
|
+
conversationId,
|
|
1152
|
+
baseCwd: config.recoveryBaseCwd ?? environment.baseCwd,
|
|
1153
|
+
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
1154
|
+
lastInboundMessageId: input.sourceMessageId,
|
|
1155
|
+
});
|
|
1156
|
+
}
|
|
1157
|
+
catch (error) {
|
|
1158
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
config.onInputCompleted?.(input.sourceMessageId);
|
|
1162
|
+
}
|
|
1156
1163
|
function getFinalTurnTrail() {
|
|
1157
1164
|
const turnId = session.currentTurnId ?? `claude-${conversationId}`;
|
|
1158
1165
|
return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
|
|
@@ -1246,6 +1253,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1246
1253
|
},
|
|
1247
1254
|
});
|
|
1248
1255
|
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1256
|
+
markInputCompleted(deliverTurn);
|
|
1249
1257
|
session.state.state = 'idle';
|
|
1250
1258
|
session.pendingFinalDelivery = null;
|
|
1251
1259
|
clearFinalDeliveryTimer();
|
|
@@ -1458,7 +1466,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1458
1466
|
sendInput(input);
|
|
1459
1467
|
}
|
|
1460
1468
|
function drainPendingInput() {
|
|
1461
|
-
if (session.closed
|
|
1469
|
+
if (session.closed
|
|
1470
|
+
|| session.controlInterruptPending
|
|
1471
|
+
|| session.state.state === 'running'
|
|
1472
|
+
|| session.pendingInputs.length === 0)
|
|
1462
1473
|
return;
|
|
1463
1474
|
const next = session.pendingInputs.shift();
|
|
1464
1475
|
if (!next)
|
|
@@ -1467,7 +1478,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1467
1478
|
writeTurn();
|
|
1468
1479
|
void startCanonInput(next);
|
|
1469
1480
|
}
|
|
1470
|
-
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, artifactRoutingMode = 'disabled') => {
|
|
1481
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, artifactRoutingMode = 'disabled') => {
|
|
1471
1482
|
session.lastActivity = Date.now();
|
|
1472
1483
|
const input = createClaudeInputEnvelope({
|
|
1473
1484
|
kind: 'canon',
|
|
@@ -1476,6 +1487,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1476
1487
|
sourceMessageId,
|
|
1477
1488
|
markAccepted,
|
|
1478
1489
|
isOwnerTurn,
|
|
1490
|
+
requestingUserId,
|
|
1479
1491
|
artifactRoutingMode,
|
|
1480
1492
|
});
|
|
1481
1493
|
if (session.pendingFinalDelivery) {
|
|
@@ -1489,15 +1501,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1489
1501
|
return;
|
|
1490
1502
|
}
|
|
1491
1503
|
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
1492
|
-
if (session.activeInput) {
|
|
1493
|
-
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
1494
|
-
}
|
|
1495
1504
|
session.pendingInputs.unshift(input);
|
|
1496
|
-
session.turnState = 'interrupted';
|
|
1497
|
-
stopVisibleWorkSignal();
|
|
1498
|
-
clearStreaming().catch(() => { });
|
|
1499
1505
|
writeTurn();
|
|
1500
|
-
|
|
1506
|
+
const interruptedInput = session.activeInput;
|
|
1507
|
+
void session.query.interrupt().then(() => {
|
|
1508
|
+
if (interruptedInput) {
|
|
1509
|
+
session.interruptedTurnKeys.add(interruptedInput.turnKey);
|
|
1510
|
+
markInputCompleted(interruptedInput);
|
|
1511
|
+
}
|
|
1512
|
+
session.turnState = 'interrupted';
|
|
1513
|
+
stopVisibleWorkSignal();
|
|
1514
|
+
clearStreaming().catch(() => { });
|
|
1515
|
+
writeTurn();
|
|
1516
|
+
}).catch((error) => {
|
|
1517
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Input interrupt failed; message remains queued:`, error instanceof Error ? error.message : error);
|
|
1518
|
+
});
|
|
1501
1519
|
return;
|
|
1502
1520
|
}
|
|
1503
1521
|
session.lastAcceptedIntent = intent;
|
|
@@ -1644,6 +1662,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1644
1662
|
// Non-fatal
|
|
1645
1663
|
}
|
|
1646
1664
|
if (finalDelivered) {
|
|
1665
|
+
if (!finalText || !shouldDeliverFinal) {
|
|
1666
|
+
markInputCompleted(completedInput);
|
|
1667
|
+
}
|
|
1647
1668
|
if (completedInput?.turnKey === session.activeInput?.turnKey) {
|
|
1648
1669
|
session.activeInput = null;
|
|
1649
1670
|
}
|
|
@@ -1722,9 +1743,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1722
1743
|
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1723
1744
|
try {
|
|
1724
1745
|
const models = await q.supportedModels();
|
|
1725
|
-
const
|
|
1726
|
-
|
|
1727
|
-
|
|
1746
|
+
const supplemental = await detectSupplementalRuntimeModels(cwd);
|
|
1747
|
+
const modelList = resolveClaudeModelOptions({
|
|
1748
|
+
discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
|
|
1728
1749
|
selectedModel: session.state.model,
|
|
1729
1750
|
});
|
|
1730
1751
|
session.availableModels = modelList;
|
|
@@ -2192,25 +2213,13 @@ export async function main() {
|
|
|
2192
2213
|
});
|
|
2193
2214
|
}
|
|
2194
2215
|
else {
|
|
2195
|
-
const existingState = loadRuntimeSessionState(runtimeId, {
|
|
2196
|
-
conversationId,
|
|
2197
|
-
baseCwd: session.environment.baseCwd,
|
|
2198
|
-
executionMode: session.environment.mode,
|
|
2199
|
-
});
|
|
2200
2216
|
savedSessionIds.delete(resumeKey);
|
|
2201
2217
|
clearRuntimeSessionState(runtimeId, {
|
|
2202
2218
|
conversationId,
|
|
2203
2219
|
baseCwd: session.environment.baseCwd,
|
|
2204
2220
|
executionMode: session.environment.mode,
|
|
2221
|
+
preserveInboundCursor: true,
|
|
2205
2222
|
});
|
|
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
2223
|
}
|
|
2215
2224
|
session.clearStreaming().catch(() => { });
|
|
2216
2225
|
if (session.idleResetTimer) {
|
|
@@ -2234,8 +2243,27 @@ export async function main() {
|
|
|
2234
2243
|
}
|
|
2235
2244
|
async function resetRuntimeSession(session) {
|
|
2236
2245
|
const conversationId = session.conversationId;
|
|
2246
|
+
const sdkTurnActive = (session.state.state === 'running'
|
|
2247
|
+
|| session.state.state === 'requires_action') && !session.pendingFinalDelivery;
|
|
2248
|
+
session.controlInterruptPending = sdkTurnActive;
|
|
2249
|
+
const interruptResult = await confirmClaudeInterrupt({
|
|
2250
|
+
active: sdkTurnActive,
|
|
2251
|
+
interrupt: () => session.query.interrupt(),
|
|
2252
|
+
});
|
|
2253
|
+
if (interruptResult === 'defer') {
|
|
2254
|
+
session.controlInterruptPending = false;
|
|
2255
|
+
throw new Error('Claude Code did not acknowledge the new-session interrupt.');
|
|
2256
|
+
}
|
|
2257
|
+
session.closed = true;
|
|
2258
|
+
if (session.finalDeliveryTimer) {
|
|
2259
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
2260
|
+
session.finalDeliveryTimer = null;
|
|
2261
|
+
}
|
|
2237
2262
|
const droppedInputs = session.pendingInputs.splice(0);
|
|
2238
2263
|
await markSessionPendingInputsRejected(conversationId, droppedInputs);
|
|
2264
|
+
session.markInputCompleted(session.activeInput);
|
|
2265
|
+
for (const input of droppedInputs)
|
|
2266
|
+
session.markInputCompleted(input);
|
|
2239
2267
|
if (session.activeInput) {
|
|
2240
2268
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2241
2269
|
}
|
|
@@ -2244,9 +2272,6 @@ export async function main() {
|
|
|
2244
2272
|
session.turnState = 'interrupted';
|
|
2245
2273
|
session.clearStreaming().catch(() => { });
|
|
2246
2274
|
typingSignals.clear(conversationId).catch(() => { });
|
|
2247
|
-
if (session.state.state === 'running') {
|
|
2248
|
-
await session.query.interrupt().catch(() => { });
|
|
2249
|
-
}
|
|
2250
2275
|
closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
|
|
2251
2276
|
}
|
|
2252
2277
|
async function markSessionPendingInputsRejected(conversationId, inputs) {
|
|
@@ -2330,10 +2355,17 @@ export async function main() {
|
|
|
2330
2355
|
conversationId: convoId,
|
|
2331
2356
|
baseCwd: existing.environment.baseCwd,
|
|
2332
2357
|
executionMode: existing.environment.mode,
|
|
2358
|
+
preserveInboundCursor: true,
|
|
2333
2359
|
});
|
|
2334
2360
|
}
|
|
2335
2361
|
}
|
|
2336
2362
|
existing?.clearStreaming().catch(() => { });
|
|
2363
|
+
if (existing) {
|
|
2364
|
+
settleInboundMessageId(existing.activeInput?.sourceMessageId, false);
|
|
2365
|
+
for (const input of existing.pendingInputs) {
|
|
2366
|
+
settleInboundMessageId(input.sourceMessageId, false);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2337
2369
|
typingSignals.clear(convoId).catch(() => { });
|
|
2338
2370
|
typingSignals.dispose(convoId);
|
|
2339
2371
|
sessions.delete(convoId);
|
|
@@ -2354,7 +2386,11 @@ export async function main() {
|
|
|
2354
2386
|
runtimeInputManager,
|
|
2355
2387
|
canRequestApproval: () => canRequestCanonApproval(conversationId),
|
|
2356
2388
|
runtimeId,
|
|
2389
|
+
recoveryBaseCwd: workingDir,
|
|
2357
2390
|
ownerId,
|
|
2391
|
+
onInputCompleted: (sourceMessageId) => {
|
|
2392
|
+
settleInboundMessageId(sourceMessageId, true);
|
|
2393
|
+
},
|
|
2358
2394
|
publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
|
|
2359
2395
|
}, resumeId);
|
|
2360
2396
|
sessions.set(conversationId, session);
|
|
@@ -2375,9 +2411,27 @@ export async function main() {
|
|
|
2375
2411
|
}
|
|
2376
2412
|
}
|
|
2377
2413
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2378
|
-
const m =
|
|
2414
|
+
const m = {
|
|
2415
|
+
id: input.message.id,
|
|
2416
|
+
senderId: input.message.senderId,
|
|
2417
|
+
senderName: input.message.senderName,
|
|
2418
|
+
senderType: input.message.senderType,
|
|
2419
|
+
isOwner: input.message.isOwner,
|
|
2420
|
+
text: input.message.text ?? undefined,
|
|
2421
|
+
contentType: input.message.contentType,
|
|
2422
|
+
attachments: input.message.attachments,
|
|
2423
|
+
mentions: input.message.mentions,
|
|
2424
|
+
reactions: input.message.reactions,
|
|
2425
|
+
replyTo: input.message.replyTo ?? undefined,
|
|
2426
|
+
replyToPosition: input.message.replyToPosition ?? undefined,
|
|
2427
|
+
forwarded: input.message.forwarded,
|
|
2428
|
+
forwardedFrom: input.message.forwardedFrom,
|
|
2429
|
+
metadata: input.message.metadata,
|
|
2430
|
+
contactCard: input.message.contactCard,
|
|
2431
|
+
createdAt: input.message.createdAt,
|
|
2432
|
+
};
|
|
2379
2433
|
if (m.senderId === agentId)
|
|
2380
|
-
return;
|
|
2434
|
+
return 'handled';
|
|
2381
2435
|
let materialized = [];
|
|
2382
2436
|
if (m.id) {
|
|
2383
2437
|
try {
|
|
@@ -2414,7 +2468,7 @@ export async function main() {
|
|
|
2414
2468
|
const autoReply = decideAutoReply(participantContext, behavior);
|
|
2415
2469
|
if (!autoReply.allow) {
|
|
2416
2470
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
|
|
2417
|
-
return;
|
|
2471
|
+
return 'handled';
|
|
2418
2472
|
}
|
|
2419
2473
|
markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
|
|
2420
2474
|
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
@@ -2437,7 +2491,7 @@ export async function main() {
|
|
|
2437
2491
|
replyBehavior: 'suppress_auto_reply',
|
|
2438
2492
|
},
|
|
2439
2493
|
}).catch(() => { });
|
|
2440
|
-
return;
|
|
2494
|
+
return 'handled';
|
|
2441
2495
|
}
|
|
2442
2496
|
session.activeSelfContextId = activeSelfContextId;
|
|
2443
2497
|
const promptText = buildCanonPrompt({
|
|
@@ -2463,15 +2517,153 @@ export async function main() {
|
|
|
2463
2517
|
content: messageContent,
|
|
2464
2518
|
},
|
|
2465
2519
|
parent_tool_use_id: null,
|
|
2466
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2520
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2521
|
+
return 'queued';
|
|
2522
|
+
}
|
|
2523
|
+
const acceptedInboundMessageIds = new Set();
|
|
2524
|
+
const inFlightInboundMessageIds = new Set();
|
|
2525
|
+
function claimInboundMessageId(messageId) {
|
|
2526
|
+
if (!messageId)
|
|
2527
|
+
return true;
|
|
2528
|
+
if (acceptedInboundMessageIds.has(messageId) || inFlightInboundMessageIds.has(messageId))
|
|
2529
|
+
return false;
|
|
2530
|
+
inFlightInboundMessageIds.add(messageId);
|
|
2531
|
+
return true;
|
|
2532
|
+
}
|
|
2533
|
+
function settleInboundMessageId(messageId, accepted) {
|
|
2534
|
+
if (!messageId)
|
|
2535
|
+
return;
|
|
2536
|
+
inFlightInboundMessageIds.delete(messageId);
|
|
2537
|
+
if (!accepted)
|
|
2538
|
+
return;
|
|
2539
|
+
acceptedInboundMessageIds.add(messageId);
|
|
2540
|
+
while (acceptedInboundMessageIds.size > 2_048) {
|
|
2541
|
+
const oldest = acceptedInboundMessageIds.values().next().value;
|
|
2542
|
+
if (!oldest)
|
|
2543
|
+
break;
|
|
2544
|
+
acceptedInboundMessageIds.delete(oldest);
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
function handleRuntimeReplyMessage(conversationId, message) {
|
|
2548
|
+
if (message.metadata?.type === 'approval_reply' || (approvalManager?.pendingCount ?? 0) > 0) {
|
|
2549
|
+
const consumed = approvalManager?.handleMessage(conversationId, {
|
|
2550
|
+
senderId: message.senderId,
|
|
2551
|
+
metadata: message.metadata,
|
|
2552
|
+
}) ?? false;
|
|
2553
|
+
if (message.metadata?.type === 'approval_reply') {
|
|
2554
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${message.senderName || message.senderId}`);
|
|
2555
|
+
return true;
|
|
2556
|
+
}
|
|
2557
|
+
if (consumed) {
|
|
2558
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Approval text reply consumed from ${message.senderName || message.senderId}`);
|
|
2559
|
+
return true;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
if (message.metadata?.type !== 'plan_approval_reply')
|
|
2563
|
+
return false;
|
|
2564
|
+
const existingSession = sessions.get(conversationId);
|
|
2565
|
+
const pendingReply = existingSession?.pendingReply;
|
|
2566
|
+
const replyRequestId = message.metadata.planId;
|
|
2567
|
+
if (!pendingReply) {
|
|
2568
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring stale ${message.metadata.type} with no pending request`);
|
|
2569
|
+
return true;
|
|
2570
|
+
}
|
|
2571
|
+
if (message.senderId !== pendingReply.responseUserId) {
|
|
2572
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring ${message.metadata.type} from unexpected responder ${message.senderId}`);
|
|
2573
|
+
return true;
|
|
2574
|
+
}
|
|
2575
|
+
if (pendingReply.type !== message.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2576
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Ignoring mismatched ${message.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2577
|
+
return true;
|
|
2473
2578
|
}
|
|
2579
|
+
existingSession.pendingReply = null;
|
|
2580
|
+
existingSession.lastActivity = Date.now();
|
|
2581
|
+
pendingReply.resolve(message.metadata);
|
|
2582
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Reply received (${message.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2583
|
+
return true;
|
|
2474
2584
|
}
|
|
2585
|
+
function persistedConversationCursors(conversationId) {
|
|
2586
|
+
const canonicalCursor = loadRuntimeSessionState(runtimeId, {
|
|
2587
|
+
conversationId,
|
|
2588
|
+
baseCwd: workingDir,
|
|
2589
|
+
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
2590
|
+
})?.lastInboundMessageId ?? null;
|
|
2591
|
+
if (canonicalCursor)
|
|
2592
|
+
return { canonicalCursor, legacyCursors: [] };
|
|
2593
|
+
const legacyCursors = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
|
|
2594
|
+
.filter((state) => state.conversationId === conversationId)
|
|
2595
|
+
.map((state) => state.lastInboundMessageId)
|
|
2596
|
+
.filter((messageId) => Boolean(messageId));
|
|
2597
|
+
return { canonicalCursor: null, legacyCursors: [...new Set(legacyCursors)] };
|
|
2598
|
+
}
|
|
2599
|
+
async function performMissedInboundRecovery() {
|
|
2600
|
+
for (const conversationId of knownConversationIds) {
|
|
2601
|
+
try {
|
|
2602
|
+
const cursors = persistedConversationCursors(conversationId);
|
|
2603
|
+
const recovered = await collectMissedInboundMessages({
|
|
2604
|
+
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
2605
|
+
cursor: cursors.canonicalCursor,
|
|
2606
|
+
cursorCandidates: cursors.legacyCursors,
|
|
2607
|
+
agentId,
|
|
2608
|
+
});
|
|
2609
|
+
if (!cursors.canonicalCursor
|
|
2610
|
+
&& recovered.cursor
|
|
2611
|
+
&& recovered.messages.length === 0) {
|
|
2612
|
+
saveRuntimeSessionState(runtimeId, {
|
|
2613
|
+
conversationId,
|
|
2614
|
+
baseCwd: workingDir,
|
|
2615
|
+
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
2616
|
+
lastInboundMessageId: recovered.cursor,
|
|
2617
|
+
});
|
|
2618
|
+
}
|
|
2619
|
+
if (recovered.mode === 'truncated-window') {
|
|
2620
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
|
|
2621
|
+
}
|
|
2622
|
+
for (const message of recovered.messages) {
|
|
2623
|
+
if (!claimInboundMessageId(message.id))
|
|
2624
|
+
continue;
|
|
2625
|
+
try {
|
|
2626
|
+
let queued = false;
|
|
2627
|
+
if (!handleRuntimeReplyMessage(conversationId, message)) {
|
|
2628
|
+
const trigger = shouldTriggerAgentTurn({
|
|
2629
|
+
senderType: message.senderType,
|
|
2630
|
+
metadata: message.metadata,
|
|
2631
|
+
});
|
|
2632
|
+
if (trigger.allow) {
|
|
2633
|
+
queued = await enqueueRecoveredInboundMessage({
|
|
2634
|
+
conversationId,
|
|
2635
|
+
message,
|
|
2636
|
+
hydratedPage: recovered.newestPage,
|
|
2637
|
+
}) === 'queued';
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
if (!queued)
|
|
2641
|
+
settleInboundMessageId(message.id, true);
|
|
2642
|
+
}
|
|
2643
|
+
catch (error) {
|
|
2644
|
+
settleInboundMessageId(message.id, false);
|
|
2645
|
+
throw error;
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
if (recovered.messages.length > 0) {
|
|
2649
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovered ${recovered.messages.length} inbound message(s) (${recovered.mode})`);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
catch (error) {
|
|
2653
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
let recoveryInFlight = null;
|
|
2658
|
+
function recoverMissedInboundMessages() {
|
|
2659
|
+
if (recoveryInFlight)
|
|
2660
|
+
return recoveryInFlight;
|
|
2661
|
+
recoveryInFlight = performMissedInboundRecovery().finally(() => {
|
|
2662
|
+
recoveryInFlight = null;
|
|
2663
|
+
});
|
|
2664
|
+
return recoveryInFlight;
|
|
2665
|
+
}
|
|
2666
|
+
await recoverMissedInboundMessages();
|
|
2475
2667
|
// ── Connect SSE stream for inbound Canon messages ──
|
|
2476
2668
|
const stream = new CanonStream({
|
|
2477
2669
|
apiKey,
|
|
@@ -2481,53 +2673,19 @@ export async function main() {
|
|
|
2481
2673
|
const m = payload.message;
|
|
2482
2674
|
if (m.senderId === agentId)
|
|
2483
2675
|
return;
|
|
2676
|
+
if (!claimInboundMessageId(m.id))
|
|
2677
|
+
return;
|
|
2484
2678
|
const convoId = payload.conversationId;
|
|
2485
2679
|
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
|
-
}
|
|
2680
|
+
if (handleRuntimeReplyMessage(convoId, m)) {
|
|
2681
|
+
settleInboundMessageId(m.id, true);
|
|
2682
|
+
return;
|
|
2526
2683
|
}
|
|
2527
2684
|
const sender = m.senderName || m.senderId;
|
|
2528
2685
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2529
2686
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2530
2687
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2688
|
+
settleInboundMessageId(m.id, true);
|
|
2531
2689
|
return;
|
|
2532
2690
|
}
|
|
2533
2691
|
void (async () => {
|
|
@@ -2572,7 +2730,7 @@ export async function main() {
|
|
|
2572
2730
|
: decideAutoReply(participantContext, behavior);
|
|
2573
2731
|
if (!autoReply.allow) {
|
|
2574
2732
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2575
|
-
return;
|
|
2733
|
+
return false;
|
|
2576
2734
|
}
|
|
2577
2735
|
markGroupContextModeUsed(convoId, participantContext.groupContextMode);
|
|
2578
2736
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
@@ -2596,7 +2754,7 @@ export async function main() {
|
|
|
2596
2754
|
replyBehavior: 'suppress_auto_reply',
|
|
2597
2755
|
},
|
|
2598
2756
|
}).catch(() => { });
|
|
2599
|
-
return;
|
|
2757
|
+
return false;
|
|
2600
2758
|
}
|
|
2601
2759
|
session.activeSelfContextId = activeSelfContextId;
|
|
2602
2760
|
const promptText = buildCanonPrompt({
|
|
@@ -2622,15 +2780,15 @@ export async function main() {
|
|
|
2622
2780
|
content: messageContent,
|
|
2623
2781
|
},
|
|
2624
2782
|
parent_tool_use_id: null,
|
|
2625
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
}
|
|
2633
|
-
})
|
|
2783
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, artifactRoutingMode);
|
|
2784
|
+
return true;
|
|
2785
|
+
})().then((queued) => {
|
|
2786
|
+
if (!queued)
|
|
2787
|
+
settleInboundMessageId(m.id, true);
|
|
2788
|
+
}).catch((error) => {
|
|
2789
|
+
settleInboundMessageId(m.id, false);
|
|
2790
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
|
|
2791
|
+
});
|
|
2634
2792
|
},
|
|
2635
2793
|
onMessageDeleted: (payload) => {
|
|
2636
2794
|
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
@@ -2641,6 +2799,7 @@ export async function main() {
|
|
|
2641
2799
|
onConnected: () => {
|
|
2642
2800
|
streamConnected = true;
|
|
2643
2801
|
void publishRuntimeHeartbeat();
|
|
2802
|
+
void recoverMissedInboundMessages();
|
|
2644
2803
|
console.error('[canon-host] SSE connected');
|
|
2645
2804
|
},
|
|
2646
2805
|
onDisconnected: () => {
|
|
@@ -2687,31 +2846,57 @@ export async function main() {
|
|
|
2687
2846
|
});
|
|
2688
2847
|
session.writeState();
|
|
2689
2848
|
}
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
// signal is reported but never retried.
|
|
2849
|
+
// Stop/new-session signals remain pending until the Claude SDK confirms the
|
|
2850
|
+
// interrupt. `defer` deliberately makes the poller revisit the same node.
|
|
2693
2851
|
async function handleRuntimeControlSignal(event) {
|
|
2694
2852
|
const convoId = event.conversationId;
|
|
2695
2853
|
const session = sessions.get(convoId);
|
|
2696
2854
|
if (!session || session.closed)
|
|
2697
|
-
return {
|
|
2855
|
+
return { defer: true };
|
|
2698
2856
|
if (event.type === 'new_session') {
|
|
2699
2857
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2700
|
-
|
|
2858
|
+
try {
|
|
2859
|
+
await resetRuntimeSession(session);
|
|
2860
|
+
}
|
|
2861
|
+
catch (error) {
|
|
2862
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] New-session interrupt failed; retrying signal:`, error instanceof Error ? error.message : error);
|
|
2863
|
+
return { defer: true };
|
|
2864
|
+
}
|
|
2701
2865
|
return;
|
|
2702
2866
|
}
|
|
2703
|
-
const isActive = session.state.state === 'running';
|
|
2867
|
+
const isActive = session.state.state === 'running' || session.state.state === 'requires_action';
|
|
2704
2868
|
if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2705
2869
|
return; // Nothing to interrupt — consume the stale signal
|
|
2706
2870
|
}
|
|
2707
2871
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2708
|
-
|
|
2709
|
-
|
|
2872
|
+
const sdkTurnActive = isActive && !session.pendingFinalDelivery;
|
|
2873
|
+
session.controlInterruptPending = sdkTurnActive;
|
|
2874
|
+
const interruptResult = await confirmClaudeInterrupt({
|
|
2875
|
+
active: sdkTurnActive,
|
|
2876
|
+
interrupt: () => session.query.interrupt(),
|
|
2877
|
+
});
|
|
2878
|
+
if (interruptResult === 'defer') {
|
|
2879
|
+
session.controlInterruptPending = false;
|
|
2880
|
+
console.error(`[canon-host] [${convoId.slice(0, 8)}] Stop interrupt failed; retrying signal`);
|
|
2881
|
+
return { defer: true };
|
|
2882
|
+
}
|
|
2883
|
+
const droppedInputs = event.type === 'stop_and_drop'
|
|
2884
|
+
? session.pendingInputs.splice(0)
|
|
2885
|
+
: [];
|
|
2886
|
+
if (droppedInputs.length > 0) {
|
|
2710
2887
|
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2711
2888
|
}
|
|
2889
|
+
session.markInputCompleted(session.activeInput);
|
|
2890
|
+
for (const input of droppedInputs)
|
|
2891
|
+
session.markInputCompleted(input);
|
|
2712
2892
|
if (session.activeInput) {
|
|
2713
2893
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2714
2894
|
}
|
|
2895
|
+
if (session.finalDeliveryTimer) {
|
|
2896
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
2897
|
+
session.finalDeliveryTimer = null;
|
|
2898
|
+
}
|
|
2899
|
+
session.pendingFinalDelivery = null;
|
|
2715
2900
|
session.pendingFinalText = null;
|
|
2716
2901
|
session.turnState = 'interrupted';
|
|
2717
2902
|
session.clearStreaming().catch(() => { });
|
|
@@ -2726,9 +2911,7 @@ export async function main() {
|
|
|
2726
2911
|
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2727
2912
|
completedAt: { '.sv': 'timestamp' },
|
|
2728
2913
|
}).catch(() => { });
|
|
2729
|
-
|
|
2730
|
-
await session.query.interrupt();
|
|
2731
|
-
}
|
|
2914
|
+
session.controlInterruptPending = false;
|
|
2732
2915
|
}
|
|
2733
2916
|
controlPoller.start();
|
|
2734
2917
|
// ── Heartbeat (per-session) ──
|