@canonmsg/claude-code-plugin 0.24.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 +448 -264
- 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 +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, 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,
|
|
@@ -769,7 +739,6 @@ async function buildCanonUserContent(input) {
|
|
|
769
739
|
function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
770
740
|
const { cwd } = environment;
|
|
771
741
|
const cardResponseUserId = config.ownerId ?? undefined;
|
|
772
|
-
const cardResponseUser = cardResponseUserId ? { responseUserId: cardResponseUserId } : undefined;
|
|
773
742
|
// Per-session input stream with message queue
|
|
774
743
|
let resolveInput = null;
|
|
775
744
|
let inputDone = false;
|
|
@@ -785,7 +754,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
785
754
|
messageQueue.push(input);
|
|
786
755
|
}
|
|
787
756
|
};
|
|
788
|
-
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') => {
|
|
789
758
|
sendInput(createClaudeInputEnvelope({
|
|
790
759
|
kind: 'canon',
|
|
791
760
|
msg,
|
|
@@ -793,6 +762,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
793
762
|
sourceMessageId,
|
|
794
763
|
markAccepted,
|
|
795
764
|
isOwnerTurn,
|
|
765
|
+
requestingUserId,
|
|
796
766
|
artifactRoutingMode,
|
|
797
767
|
}));
|
|
798
768
|
};
|
|
@@ -826,6 +796,73 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
826
796
|
// eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
|
|
827
797
|
let session;
|
|
828
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
|
+
}
|
|
829
866
|
function waitForReply(type, requestId) {
|
|
830
867
|
// Keep session alive while waiting for user input
|
|
831
868
|
session.lastActivity = Date.now();
|
|
@@ -844,30 +881,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
844
881
|
};
|
|
845
882
|
});
|
|
846
883
|
}
|
|
847
|
-
async function waitForRuntimeInputResponse(input) {
|
|
848
|
-
session.lastActivity = Date.now();
|
|
849
|
-
while (Date.now() < input.expiresAt) {
|
|
850
|
-
const response = await client.consumeRuntimeInputResponse({
|
|
851
|
-
conversationId,
|
|
852
|
-
inputId: input.inputId,
|
|
853
|
-
}).catch(() => null);
|
|
854
|
-
if (response?.status === 'submitted') {
|
|
855
|
-
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
856
|
-
}
|
|
857
|
-
if (response?.status === 'cancelled' || response?.status === 'timeout') {
|
|
858
|
-
return { status: response.status };
|
|
859
|
-
}
|
|
860
|
-
await sleep(1_000);
|
|
861
|
-
}
|
|
862
|
-
const response = await client.consumeRuntimeInputResponse({
|
|
863
|
-
conversationId,
|
|
864
|
-
inputId: input.inputId,
|
|
865
|
-
}).catch(() => null);
|
|
866
|
-
if (response?.status === 'submitted') {
|
|
867
|
-
return { status: 'submitted', answers: response.answers, value: response.value };
|
|
868
|
-
}
|
|
869
|
-
return { status: 'timeout' };
|
|
870
|
-
}
|
|
871
884
|
function buildArtifactMediaMetadata() {
|
|
872
885
|
return {
|
|
873
886
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
@@ -882,6 +895,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
882
895
|
const queryOptions = {
|
|
883
896
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
884
897
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
898
|
+
model: 'default',
|
|
885
899
|
permissionMode: parseClaudePermissionMode(config.permissionMode) ?? 'default',
|
|
886
900
|
allowDangerouslySkipPermissions: true,
|
|
887
901
|
cwd,
|
|
@@ -897,52 +911,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
897
911
|
console.error(`[claude-cli] [${conversationId.slice(0, 8)}] ${trimmed}`);
|
|
898
912
|
}
|
|
899
913
|
},
|
|
900
|
-
|
|
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) => {
|
|
901
924
|
if (toolName === 'AskUserQuestion') {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
await client.createRuntimeInputRequest({
|
|
907
|
-
conversationId,
|
|
908
|
-
inputId: questionId,
|
|
909
|
-
kind: 'clarify',
|
|
910
|
-
expiresAt,
|
|
911
|
-
title: 'Claude needs input',
|
|
912
|
-
prompt: questions?.length
|
|
913
|
-
? 'Claude needs your input to continue.'
|
|
914
|
-
: 'Claude needs input to continue.',
|
|
915
|
-
...(questions ? { questions } : {}),
|
|
916
|
-
sensitive: Boolean(questions?.some((question) => question.isSecret)),
|
|
917
|
-
...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
|
|
918
|
-
native: {
|
|
919
|
-
runtime: 'claude-code',
|
|
920
|
-
method: 'AskUserQuestion',
|
|
921
|
-
requestId: questionId,
|
|
922
|
-
turnId: session.currentTurnId ?? undefined,
|
|
923
|
-
},
|
|
924
|
-
turnId: session.currentTurnId ?? undefined,
|
|
925
|
-
});
|
|
926
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Runtime input question sent (${questionId.slice(0, 8)})`);
|
|
927
|
-
const reply = await waitForRuntimeInputResponse({ inputId: questionId, expiresAt });
|
|
928
|
-
if (reply.status !== 'submitted') {
|
|
929
|
-
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
930
|
-
}
|
|
931
|
-
return {
|
|
932
|
-
behavior: 'allow',
|
|
933
|
-
updatedInput: {
|
|
934
|
-
...input,
|
|
935
|
-
answers: reply.answers
|
|
936
|
-
? flattenRuntimeInputAnswers(reply.answers)
|
|
937
|
-
: reply.value
|
|
938
|
-
? { answer: reply.value }
|
|
939
|
-
: {},
|
|
940
|
-
},
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
catch {
|
|
944
|
-
return { behavior: 'deny', message: 'No reply received (timed out)' };
|
|
945
|
-
}
|
|
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);
|
|
946
929
|
}
|
|
947
930
|
if (toolName === 'ExitPlanMode') {
|
|
948
931
|
try {
|
|
@@ -970,22 +953,20 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
970
953
|
const planBody = typeof input.plan === 'string' && input.plan.trim()
|
|
971
954
|
? input.plan.trim()
|
|
972
955
|
: undefined;
|
|
973
|
-
|
|
956
|
+
// Route plan-CREATE through the unified spine (`/runtime-plan/request`):
|
|
957
|
+
// seeds the server-owned pending node/attention/state and authors the
|
|
958
|
+
// `plan_approval` card. Resolution is UNCHANGED — the gate still blocks
|
|
959
|
+
// on the server-authored `plan_approval_reply` below.
|
|
960
|
+
await client.createRuntimePlanRequest({
|
|
961
|
+
conversationId,
|
|
962
|
+
planId,
|
|
963
|
+
title: 'Plan ready for review.',
|
|
974
964
|
summary: 'Claude has prepared a plan and is asking permission to continue.',
|
|
975
965
|
...(planBody ? { body: planBody } : {}),
|
|
976
|
-
...(
|
|
966
|
+
...(cardResponseUserId ? { responseUserId: cardResponseUserId } : {}),
|
|
977
967
|
...(allowedPrompts.length > 0 ? { allowedPrompts } : {}),
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
messageId: `claude-plan-${planId}`,
|
|
981
|
-
...(session.activeSelfContextId
|
|
982
|
-
? { selfContextId: session.activeSelfContextId }
|
|
983
|
-
: {}),
|
|
984
|
-
metadata: {
|
|
985
|
-
...planApproval.metadata,
|
|
986
|
-
turnSemantics: 'control',
|
|
987
|
-
replyBehavior: 'suppress_auto_reply',
|
|
988
|
-
},
|
|
968
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
969
|
+
expiresAt: Date.now() + REPLY_TIMEOUT_MS,
|
|
989
970
|
});
|
|
990
971
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
|
|
991
972
|
const reply = await waitForReply('plan_approval_reply', planId);
|
|
@@ -1094,7 +1075,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1094
1075
|
environment,
|
|
1095
1076
|
query: q,
|
|
1096
1077
|
sendInput,
|
|
1097
|
-
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),
|
|
1098
1079
|
state: {
|
|
1099
1080
|
model: undefined,
|
|
1100
1081
|
permissionMode: config.permissionMode,
|
|
@@ -1124,12 +1105,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1124
1105
|
lastAcceptedIntent: null,
|
|
1125
1106
|
lastActivity: Date.now(),
|
|
1126
1107
|
typingKeepaliveTimer: null,
|
|
1108
|
+
controlInterruptPending: false,
|
|
1127
1109
|
closed: false,
|
|
1128
1110
|
sdkSessionId: resumeSessionId || null,
|
|
1129
1111
|
pendingReply: null,
|
|
1130
1112
|
writeState: () => writeState(),
|
|
1131
1113
|
writeTurn: () => writeTurn(),
|
|
1132
1114
|
clearStreaming: () => clearStreaming(),
|
|
1115
|
+
markInputCompleted: (input) => markInputCompleted(input),
|
|
1133
1116
|
setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
|
|
1134
1117
|
clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
|
|
1135
1118
|
};
|
|
@@ -1174,6 +1157,24 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1174
1157
|
function clearStreaming() {
|
|
1175
1158
|
return streamingOutput.clear().catch(() => { });
|
|
1176
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
|
+
}
|
|
1177
1178
|
function getFinalTurnTrail() {
|
|
1178
1179
|
const turnId = session.currentTurnId ?? `claude-${conversationId}`;
|
|
1179
1180
|
return streamingOutput.getFinalTrail().map((block) => ({ ...block, turnId }));
|
|
@@ -1267,6 +1268,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1267
1268
|
},
|
|
1268
1269
|
});
|
|
1269
1270
|
session.finalizedTurnKeys.add(deliverTurn.turnKey);
|
|
1271
|
+
markInputCompleted(deliverTurn);
|
|
1270
1272
|
session.state.state = 'idle';
|
|
1271
1273
|
session.pendingFinalDelivery = null;
|
|
1272
1274
|
clearFinalDeliveryTimer();
|
|
@@ -1479,7 +1481,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1479
1481
|
sendInput(input);
|
|
1480
1482
|
}
|
|
1481
1483
|
function drainPendingInput() {
|
|
1482
|
-
if (session.closed
|
|
1484
|
+
if (session.closed
|
|
1485
|
+
|| session.controlInterruptPending
|
|
1486
|
+
|| session.state.state === 'running'
|
|
1487
|
+
|| session.pendingInputs.length === 0)
|
|
1483
1488
|
return;
|
|
1484
1489
|
const next = session.pendingInputs.shift();
|
|
1485
1490
|
if (!next)
|
|
@@ -1488,7 +1493,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1488
1493
|
writeTurn();
|
|
1489
1494
|
void startCanonInput(next);
|
|
1490
1495
|
}
|
|
1491
|
-
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') => {
|
|
1492
1497
|
session.lastActivity = Date.now();
|
|
1493
1498
|
const input = createClaudeInputEnvelope({
|
|
1494
1499
|
kind: 'canon',
|
|
@@ -1497,6 +1502,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1497
1502
|
sourceMessageId,
|
|
1498
1503
|
markAccepted,
|
|
1499
1504
|
isOwnerTurn,
|
|
1505
|
+
requestingUserId,
|
|
1500
1506
|
artifactRoutingMode,
|
|
1501
1507
|
});
|
|
1502
1508
|
if (session.pendingFinalDelivery) {
|
|
@@ -1510,15 +1516,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1510
1516
|
return;
|
|
1511
1517
|
}
|
|
1512
1518
|
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
1513
|
-
if (session.activeInput) {
|
|
1514
|
-
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
1515
|
-
}
|
|
1516
1519
|
session.pendingInputs.unshift(input);
|
|
1517
|
-
session.turnState = 'interrupted';
|
|
1518
|
-
stopVisibleWorkSignal();
|
|
1519
|
-
clearStreaming().catch(() => { });
|
|
1520
1520
|
writeTurn();
|
|
1521
|
-
|
|
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
|
+
});
|
|
1522
1534
|
return;
|
|
1523
1535
|
}
|
|
1524
1536
|
session.lastAcceptedIntent = intent;
|
|
@@ -1665,6 +1677,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1665
1677
|
// Non-fatal
|
|
1666
1678
|
}
|
|
1667
1679
|
if (finalDelivered) {
|
|
1680
|
+
if (!finalText || !shouldDeliverFinal) {
|
|
1681
|
+
markInputCompleted(completedInput);
|
|
1682
|
+
}
|
|
1668
1683
|
if (completedInput?.turnKey === session.activeInput?.turnKey) {
|
|
1669
1684
|
session.activeInput = null;
|
|
1670
1685
|
}
|
|
@@ -1743,9 +1758,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1743
1758
|
// Refresh the canonical runtime descriptor after Claude reports supported models.
|
|
1744
1759
|
try {
|
|
1745
1760
|
const models = await q.supportedModels();
|
|
1746
|
-
const
|
|
1747
|
-
|
|
1748
|
-
|
|
1761
|
+
const supplemental = await detectSupplementalRuntimeModels(cwd);
|
|
1762
|
+
const modelList = resolveClaudeModelOptions({
|
|
1763
|
+
discovered: mergeClaudeDiscoveredModelOptions(toModelOptions(models), ...supplemental),
|
|
1749
1764
|
selectedModel: session.state.model,
|
|
1750
1765
|
});
|
|
1751
1766
|
session.availableModels = modelList;
|
|
@@ -2062,6 +2077,10 @@ export async function main() {
|
|
|
2062
2077
|
// ── Session manager ──
|
|
2063
2078
|
const sessions = new Map();
|
|
2064
2079
|
const approvalManager = ownerId ? new ApprovalManager(client, agentId, ownerId) : null;
|
|
2080
|
+
// Interactive runtime input (AskUserQuestion) works with or without an owner,
|
|
2081
|
+
// so its poll engine is always present. Create + poll fold onto the built-in
|
|
2082
|
+
// input descriptor via `request()` (see the AskUserQuestion canUseTool branch).
|
|
2083
|
+
const runtimeInputManager = new RuntimeRequestManager(client, { agentId, ownerId: ownerId ?? '' });
|
|
2065
2084
|
const pendingSessionCreations = new Map();
|
|
2066
2085
|
// Shared /control channel poller (claude host profile): session + signal
|
|
2067
2086
|
// keys, sequential conversations, active/idle cadence + jitter sampled at
|
|
@@ -2209,25 +2228,13 @@ export async function main() {
|
|
|
2209
2228
|
});
|
|
2210
2229
|
}
|
|
2211
2230
|
else {
|
|
2212
|
-
const existingState = loadRuntimeSessionState(runtimeId, {
|
|
2213
|
-
conversationId,
|
|
2214
|
-
baseCwd: session.environment.baseCwd,
|
|
2215
|
-
executionMode: session.environment.mode,
|
|
2216
|
-
});
|
|
2217
2231
|
savedSessionIds.delete(resumeKey);
|
|
2218
2232
|
clearRuntimeSessionState(runtimeId, {
|
|
2219
2233
|
conversationId,
|
|
2220
2234
|
baseCwd: session.environment.baseCwd,
|
|
2221
2235
|
executionMode: session.environment.mode,
|
|
2236
|
+
preserveInboundCursor: true,
|
|
2222
2237
|
});
|
|
2223
|
-
if (existingState?.lastInboundMessageId) {
|
|
2224
|
-
saveRuntimeSessionState(runtimeId, {
|
|
2225
|
-
conversationId,
|
|
2226
|
-
baseCwd: session.environment.baseCwd,
|
|
2227
|
-
executionMode: session.environment.mode,
|
|
2228
|
-
lastInboundMessageId: existingState.lastInboundMessageId,
|
|
2229
|
-
});
|
|
2230
|
-
}
|
|
2231
2238
|
}
|
|
2232
2239
|
session.clearStreaming().catch(() => { });
|
|
2233
2240
|
if (session.idleResetTimer) {
|
|
@@ -2251,8 +2258,27 @@ export async function main() {
|
|
|
2251
2258
|
}
|
|
2252
2259
|
async function resetRuntimeSession(session) {
|
|
2253
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
|
+
}
|
|
2254
2277
|
const droppedInputs = session.pendingInputs.splice(0);
|
|
2255
2278
|
await markSessionPendingInputsRejected(conversationId, droppedInputs);
|
|
2279
|
+
session.markInputCompleted(session.activeInput);
|
|
2280
|
+
for (const input of droppedInputs)
|
|
2281
|
+
session.markInputCompleted(input);
|
|
2256
2282
|
if (session.activeInput) {
|
|
2257
2283
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2258
2284
|
}
|
|
@@ -2261,9 +2287,6 @@ export async function main() {
|
|
|
2261
2287
|
session.turnState = 'interrupted';
|
|
2262
2288
|
session.clearStreaming().catch(() => { });
|
|
2263
2289
|
typingSignals.clear(conversationId).catch(() => { });
|
|
2264
|
-
if (session.state.state === 'running') {
|
|
2265
|
-
await session.query.interrupt().catch(() => { });
|
|
2266
|
-
}
|
|
2267
2290
|
closeSession(conversationId, { saveResume: false, reason: 'reset for new Canon session' });
|
|
2268
2291
|
}
|
|
2269
2292
|
async function markSessionPendingInputsRejected(conversationId, inputs) {
|
|
@@ -2347,10 +2370,17 @@ export async function main() {
|
|
|
2347
2370
|
conversationId: convoId,
|
|
2348
2371
|
baseCwd: existing.environment.baseCwd,
|
|
2349
2372
|
executionMode: existing.environment.mode,
|
|
2373
|
+
preserveInboundCursor: true,
|
|
2350
2374
|
});
|
|
2351
2375
|
}
|
|
2352
2376
|
}
|
|
2353
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
|
+
}
|
|
2354
2384
|
typingSignals.clear(convoId).catch(() => { });
|
|
2355
2385
|
typingSignals.dispose(convoId);
|
|
2356
2386
|
sessions.delete(convoId);
|
|
@@ -2368,9 +2398,14 @@ export async function main() {
|
|
|
2368
2398
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
2369
2399
|
runtimePresentation,
|
|
2370
2400
|
approvalManager,
|
|
2401
|
+
runtimeInputManager,
|
|
2371
2402
|
canRequestApproval: () => canRequestCanonApproval(conversationId),
|
|
2372
2403
|
runtimeId,
|
|
2404
|
+
recoveryBaseCwd: workingDir,
|
|
2373
2405
|
ownerId,
|
|
2406
|
+
onInputCompleted: (sourceMessageId) => {
|
|
2407
|
+
settleInboundMessageId(sourceMessageId, true);
|
|
2408
|
+
},
|
|
2374
2409
|
publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
|
|
2375
2410
|
}, resumeId);
|
|
2376
2411
|
sessions.set(conversationId, session);
|
|
@@ -2391,9 +2426,27 @@ export async function main() {
|
|
|
2391
2426
|
}
|
|
2392
2427
|
}
|
|
2393
2428
|
async function enqueueRecoveredInboundMessage(input) {
|
|
2394
|
-
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
|
+
};
|
|
2395
2448
|
if (m.senderId === agentId)
|
|
2396
|
-
return;
|
|
2449
|
+
return 'handled';
|
|
2397
2450
|
let materialized = [];
|
|
2398
2451
|
if (m.id) {
|
|
2399
2452
|
try {
|
|
@@ -2430,7 +2483,7 @@ export async function main() {
|
|
|
2430
2483
|
const autoReply = decideAutoReply(participantContext, behavior);
|
|
2431
2484
|
if (!autoReply.allow) {
|
|
2432
2485
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
|
|
2433
|
-
return;
|
|
2486
|
+
return 'handled';
|
|
2434
2487
|
}
|
|
2435
2488
|
markGroupContextModeUsed(input.conversationId, participantContext.groupContextMode);
|
|
2436
2489
|
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
@@ -2453,7 +2506,7 @@ export async function main() {
|
|
|
2453
2506
|
replyBehavior: 'suppress_auto_reply',
|
|
2454
2507
|
},
|
|
2455
2508
|
}).catch(() => { });
|
|
2456
|
-
return;
|
|
2509
|
+
return 'handled';
|
|
2457
2510
|
}
|
|
2458
2511
|
session.activeSelfContextId = activeSelfContextId;
|
|
2459
2512
|
const promptText = buildCanonPrompt({
|
|
@@ -2479,15 +2532,154 @@ export async function main() {
|
|
|
2479
2532
|
content: messageContent,
|
|
2480
2533
|
},
|
|
2481
2534
|
parent_tool_use_id: null,
|
|
2482
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
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;
|
|
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;
|
|
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
|
+
}
|
|
2489
2671
|
}
|
|
2490
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();
|
|
2491
2683
|
// ── Connect SSE stream for inbound Canon messages ──
|
|
2492
2684
|
const stream = new CanonStream({
|
|
2493
2685
|
apiKey,
|
|
@@ -2497,53 +2689,19 @@ export async function main() {
|
|
|
2497
2689
|
const m = payload.message;
|
|
2498
2690
|
if (m.senderId === agentId)
|
|
2499
2691
|
return;
|
|
2692
|
+
if (!claimInboundMessageId(m.id))
|
|
2693
|
+
return;
|
|
2500
2694
|
const convoId = payload.conversationId;
|
|
2501
2695
|
knownConversationIds.add(convoId);
|
|
2502
|
-
if (
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
metadata: m.metadata,
|
|
2506
|
-
}) ?? false;
|
|
2507
|
-
if (m.metadata?.type === 'approval_reply') {
|
|
2508
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${consumed ? 'Approval reply consumed' : 'Ignoring stale approval reply'} from ${m.senderName || m.senderId}`);
|
|
2509
|
-
return;
|
|
2510
|
-
}
|
|
2511
|
-
if (consumed) {
|
|
2512
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Approval text reply consumed from ${m.senderName || m.senderId}`);
|
|
2513
|
-
return;
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
// Check for metadata replies (plan approvals).
|
|
2517
|
-
if (m.metadata?.type === 'plan_approval_reply') {
|
|
2518
|
-
const existingSession = sessions.get(convoId);
|
|
2519
|
-
const pendingReply = existingSession?.pendingReply;
|
|
2520
|
-
const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2521
|
-
const replyRequestId = m.metadata.planId;
|
|
2522
|
-
if (!pendingReply) {
|
|
2523
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
|
|
2524
|
-
return;
|
|
2525
|
-
}
|
|
2526
|
-
if (!isOwnerReply) {
|
|
2527
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
|
|
2528
|
-
return;
|
|
2529
|
-
}
|
|
2530
|
-
if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
|
|
2531
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
|
|
2532
|
-
return;
|
|
2533
|
-
}
|
|
2534
|
-
if (existingSession?.pendingReply) {
|
|
2535
|
-
const { resolve } = existingSession.pendingReply;
|
|
2536
|
-
existingSession.pendingReply = null;
|
|
2537
|
-
existingSession.lastActivity = Date.now();
|
|
2538
|
-
resolve(m.metadata);
|
|
2539
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
|
|
2540
|
-
return;
|
|
2541
|
-
}
|
|
2696
|
+
if (handleRuntimeReplyMessage(convoId, m)) {
|
|
2697
|
+
settleInboundMessageId(m.id, true);
|
|
2698
|
+
return;
|
|
2542
2699
|
}
|
|
2543
2700
|
const sender = m.senderName || m.senderId;
|
|
2544
2701
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
2545
2702
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
2546
2703
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
2704
|
+
settleInboundMessageId(m.id, true);
|
|
2547
2705
|
return;
|
|
2548
2706
|
}
|
|
2549
2707
|
void (async () => {
|
|
@@ -2588,7 +2746,7 @@ export async function main() {
|
|
|
2588
2746
|
: decideAutoReply(participantContext, behavior);
|
|
2589
2747
|
if (!autoReply.allow) {
|
|
2590
2748
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
2591
|
-
return;
|
|
2749
|
+
return false;
|
|
2592
2750
|
}
|
|
2593
2751
|
markGroupContextModeUsed(convoId, participantContext.groupContextMode);
|
|
2594
2752
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Message from ${sender}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
@@ -2612,7 +2770,7 @@ export async function main() {
|
|
|
2612
2770
|
replyBehavior: 'suppress_auto_reply',
|
|
2613
2771
|
},
|
|
2614
2772
|
}).catch(() => { });
|
|
2615
|
-
return;
|
|
2773
|
+
return false;
|
|
2616
2774
|
}
|
|
2617
2775
|
session.activeSelfContextId = activeSelfContextId;
|
|
2618
2776
|
const promptText = buildCanonPrompt({
|
|
@@ -2638,15 +2796,15 @@ export async function main() {
|
|
|
2638
2796
|
content: messageContent,
|
|
2639
2797
|
},
|
|
2640
2798
|
parent_tool_use_id: null,
|
|
2641
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, artifactRoutingMode);
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
}
|
|
2649
|
-
})
|
|
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
|
+
});
|
|
2650
2808
|
},
|
|
2651
2809
|
onMessageDeleted: (payload) => {
|
|
2652
2810
|
removeQueuedInput(payload.conversationId, payload.messageId);
|
|
@@ -2657,6 +2815,7 @@ export async function main() {
|
|
|
2657
2815
|
onConnected: () => {
|
|
2658
2816
|
streamConnected = true;
|
|
2659
2817
|
void publishRuntimeHeartbeat();
|
|
2818
|
+
void recoverMissedInboundMessages();
|
|
2660
2819
|
console.error('[canon-host] SSE connected');
|
|
2661
2820
|
},
|
|
2662
2821
|
onDisconnected: () => {
|
|
@@ -2703,31 +2862,57 @@ export async function main() {
|
|
|
2703
2862
|
});
|
|
2704
2863
|
session.writeState();
|
|
2705
2864
|
}
|
|
2706
|
-
//
|
|
2707
|
-
//
|
|
2708
|
-
// 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.
|
|
2709
2867
|
async function handleRuntimeControlSignal(event) {
|
|
2710
2868
|
const convoId = event.conversationId;
|
|
2711
2869
|
const session = sessions.get(convoId);
|
|
2712
2870
|
if (!session || session.closed)
|
|
2713
|
-
return {
|
|
2871
|
+
return { defer: true };
|
|
2714
2872
|
if (event.type === 'new_session') {
|
|
2715
2873
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] new_session signal`);
|
|
2716
|
-
|
|
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
|
+
}
|
|
2717
2881
|
return;
|
|
2718
2882
|
}
|
|
2719
|
-
const isActive = session.state.state === 'running';
|
|
2883
|
+
const isActive = session.state.state === 'running' || session.state.state === 'requires_action';
|
|
2720
2884
|
if (!isActive && (event.type !== 'stop_and_drop' || session.pendingInputs.length === 0)) {
|
|
2721
2885
|
return; // Nothing to interrupt — consume the stale signal
|
|
2722
2886
|
}
|
|
2723
2887
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] ${event.type} signal`);
|
|
2724
|
-
|
|
2725
|
-
|
|
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) {
|
|
2726
2903
|
await markSessionPendingInputsRejected(convoId, droppedInputs);
|
|
2727
2904
|
}
|
|
2905
|
+
session.markInputCompleted(session.activeInput);
|
|
2906
|
+
for (const input of droppedInputs)
|
|
2907
|
+
session.markInputCompleted(input);
|
|
2728
2908
|
if (session.activeInput) {
|
|
2729
2909
|
session.interruptedTurnKeys.add(session.activeInput.turnKey);
|
|
2730
2910
|
}
|
|
2911
|
+
if (session.finalDeliveryTimer) {
|
|
2912
|
+
clearTimeout(session.finalDeliveryTimer);
|
|
2913
|
+
session.finalDeliveryTimer = null;
|
|
2914
|
+
}
|
|
2915
|
+
session.pendingFinalDelivery = null;
|
|
2731
2916
|
session.pendingFinalText = null;
|
|
2732
2917
|
session.turnState = 'interrupted';
|
|
2733
2918
|
session.clearStreaming().catch(() => { });
|
|
@@ -2742,9 +2927,7 @@ export async function main() {
|
|
|
2742
2927
|
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
2743
2928
|
completedAt: { '.sv': 'timestamp' },
|
|
2744
2929
|
}).catch(() => { });
|
|
2745
|
-
|
|
2746
|
-
await session.query.interrupt();
|
|
2747
|
-
}
|
|
2930
|
+
session.controlInterruptPending = false;
|
|
2748
2931
|
}
|
|
2749
2932
|
controlPoller.start();
|
|
2750
2933
|
// ── Heartbeat (per-session) ──
|
|
@@ -2778,6 +2961,7 @@ export async function main() {
|
|
|
2778
2961
|
clearInterval(idleCheck);
|
|
2779
2962
|
stream.stop();
|
|
2780
2963
|
approvalManager?.dispose();
|
|
2964
|
+
runtimeInputManager.dispose();
|
|
2781
2965
|
await runtimeState.clearAgentRuntime().catch(() => { });
|
|
2782
2966
|
for (const convoId of [...sessions.keys()]) {
|
|
2783
2967
|
closeSession(convoId);
|