@canonmsg/claude-code-plugin 0.33.0 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/agent-context-state.d.ts +15 -0
- package/dist/agent-context-state.js +35 -0
- package/dist/communication-policy.d.ts +2 -0
- package/dist/communication-policy.js +4 -0
- package/dist/host.d.ts +1 -3
- package/dist/host.js +78 -144
- package/dist/inbound-reply-authority.d.ts +42 -0
- package/dist/inbound-reply-authority.js +107 -0
- package/dist/mcp-args.d.ts +3 -0
- package/dist/mcp-args.js +12 -0
- package/dist/register.js +2 -0
- package/dist/server.js +246 -141
- package/dist/session-state.d.ts +6 -42
- package/dist/session-state.js +1 -71
- package/dist/tool-policy.js +0 -1
- package/package.json +5 -5
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AgentContext } from '@canonmsg/core';
|
|
2
|
+
/** Coordinates first identity discovery with the MCP client's cached tool list. */
|
|
3
|
+
export declare class AgentContextState {
|
|
4
|
+
private readonly onCommunicationAvailabilityChanged;
|
|
5
|
+
private resolveReady;
|
|
6
|
+
private settled;
|
|
7
|
+
private contextValue;
|
|
8
|
+
readonly ready: Promise<void>;
|
|
9
|
+
constructor(onCommunicationAvailabilityChanged: () => void);
|
|
10
|
+
get current(): AgentContext | null;
|
|
11
|
+
set(context: AgentContext): void;
|
|
12
|
+
/** Resolve tools/list even when startup cannot obtain a trusted identity. */
|
|
13
|
+
settleUnavailable(): void;
|
|
14
|
+
private settle;
|
|
15
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { isProactiveCommunicationEnabled } from './communication-policy.js';
|
|
2
|
+
/** Coordinates first identity discovery with the MCP client's cached tool list. */
|
|
3
|
+
export class AgentContextState {
|
|
4
|
+
onCommunicationAvailabilityChanged;
|
|
5
|
+
resolveReady;
|
|
6
|
+
settled = false;
|
|
7
|
+
contextValue = null;
|
|
8
|
+
ready = new Promise((resolve) => {
|
|
9
|
+
this.resolveReady = resolve;
|
|
10
|
+
});
|
|
11
|
+
constructor(onCommunicationAvailabilityChanged) {
|
|
12
|
+
this.onCommunicationAvailabilityChanged = onCommunicationAvailabilityChanged;
|
|
13
|
+
}
|
|
14
|
+
get current() {
|
|
15
|
+
return this.contextValue;
|
|
16
|
+
}
|
|
17
|
+
set(context) {
|
|
18
|
+
const wasEnabled = isProactiveCommunicationEnabled(this.contextValue?.outboundPolicy);
|
|
19
|
+
this.contextValue = context;
|
|
20
|
+
this.settle();
|
|
21
|
+
const isEnabled = isProactiveCommunicationEnabled(context.outboundPolicy);
|
|
22
|
+
if (wasEnabled !== isEnabled)
|
|
23
|
+
this.onCommunicationAvailabilityChanged();
|
|
24
|
+
}
|
|
25
|
+
/** Resolve tools/list even when startup cannot obtain a trusted identity. */
|
|
26
|
+
settleUnavailable() {
|
|
27
|
+
this.settle();
|
|
28
|
+
}
|
|
29
|
+
settle() {
|
|
30
|
+
if (this.settled)
|
|
31
|
+
return;
|
|
32
|
+
this.settled = true;
|
|
33
|
+
this.resolveReady();
|
|
34
|
+
}
|
|
35
|
+
}
|
package/dist/host.d.ts
CHANGED
|
@@ -25,9 +25,7 @@ export declare function buildClaudePlanAllowResult(input: Record<string, unknown
|
|
|
25
25
|
* through. Canon-injected aliases keep precedence over native names.
|
|
26
26
|
*/
|
|
27
27
|
export declare function buildNativeSlashCommandDescriptors(native: SDKControlReloadPluginsResponse['commands'], reservedAliases: ReadonlySet<string>): CanonRuntimeCommandDescriptor[];
|
|
28
|
-
export declare function resolveSessionExecutionMode(
|
|
29
|
-
executionMode?: ExecutionEnvironmentMode;
|
|
30
|
-
} | null | undefined, defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
|
|
28
|
+
export declare function resolveSessionExecutionMode(defaultExecutionMode?: ExecutionEnvironmentMode): ExecutionEnvironmentMode;
|
|
31
29
|
export declare function createClaudeRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
|
|
32
30
|
export declare const NO_REPLY_TOOL_NAME: string;
|
|
33
31
|
export declare function buildCanonPrompt(input: {
|
package/dist/host.js
CHANGED
|
@@ -29,13 +29,14 @@ import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
|
29
29
|
import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
30
30
|
import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, collectMissedInboundMessages, createRecoveryCheckpointTracker, createReconnectRecoveryCoordinator, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
|
|
31
31
|
import { buildCanonUserContent, renderClaudeInboundContent, withClaudeReplyContextMediaReferences, } from './canon-user-content.js';
|
|
32
|
-
import { 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,
|
|
32
|
+
import { 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, ExecutionEnvironmentError, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, resolveLocalRuntimeSessionState, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveConfiguredWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
33
33
|
import { runCli } from '@canonmsg/core';
|
|
34
34
|
import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
|
|
35
35
|
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
36
36
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
37
|
-
import {
|
|
37
|
+
import { boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, beginClaudeAssistantResponse, claimClaudeTextSegmentId, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, endClaudeAssistantResponse, isClaudeTurnSlotReserved, readClaudeFinalDeliveryResume, releaseClaudeTurnSlot, runClaudeExhaustedFinalDelivery, shouldApplyClaudeEchoedSessionState, shouldReleaseClaudeTurnSlot, isClaudeMainTurnMessage, openClaudeTurn, planClaudeAssistantTrail, planClaudeStreamingWrite, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults, claudeFinalTurnTrail, publishedClaudeTurnState, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, shouldStopTypingDotsOnStreamedText, isOpenClaudeTurnState, takeClaudeResultOwner, takeClaudeToolBlockIdByIndex, claudeModelInfoToOption, claudeOriginForCanonSender, composeClaudeTurnFinal, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, shouldRouteClaudeTurnArtifacts, MINIMUM_CLAUDE_CLI_VERSION, } from './session-state.js';
|
|
38
38
|
import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
|
|
39
|
+
import { isProactiveCommunicationEnabled } from './communication-policy.js';
|
|
39
40
|
function parseRuntimeVisibilityPreset(value) {
|
|
40
41
|
return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
|
|
41
42
|
}
|
|
@@ -88,12 +89,10 @@ let workspaceRoots = [];
|
|
|
88
89
|
let workspaceRootMetadata = [];
|
|
89
90
|
let runtimeModels = [];
|
|
90
91
|
const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
92
|
+
const LOCAL_CONFIGURATION_REQUIRED_MESSAGE = 'This agent needs local runtime configuration before it can start this conversation. '
|
|
93
|
+
+ 'Its operator must configure it and retry.';
|
|
91
94
|
const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
|
|
92
|
-
/**
|
|
93
|
-
* Agent-developer setting, resolved once at startup. Deliberately NOT read from
|
|
94
|
-
* `/session-config`: that path is the USER's per-conversation control plane,
|
|
95
|
-
* and owner ruling 5 puts turn verbosity outside user control.
|
|
96
|
-
*/
|
|
95
|
+
/** Turn verbosity is local agent-developer configuration, resolved at startup. */
|
|
97
96
|
let configuredTurnVerbosity = null;
|
|
98
97
|
/**
|
|
99
98
|
* `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
|
|
@@ -418,24 +417,8 @@ function toModelOptions(models) {
|
|
|
418
417
|
async function publishAgentRuntime(agentId, runtime, rtdb) {
|
|
419
418
|
await publishHostAgentRuntime(agentId, 'claude-code', runtime, rtdb);
|
|
420
419
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
conversationId,
|
|
424
|
-
agentId,
|
|
425
|
-
rtdb,
|
|
426
|
-
extraStringFields: ['permissionMode', 'effort'],
|
|
427
|
-
});
|
|
428
|
-
return {
|
|
429
|
-
...config,
|
|
430
|
-
permissionMode: config?.permissionMode
|
|
431
|
-
? parseClaudePermissionMode(config.permissionMode) ?? undefined
|
|
432
|
-
: undefined,
|
|
433
|
-
effort: normalizeClaudeEffortLevel(config?.effort),
|
|
434
|
-
ultracode: parseClaudeUltracodeMode(config?.runtimeControlValues?.ultracode),
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
export function resolveSessionExecutionMode(config, defaultExecutionMode = 'worktree') {
|
|
438
|
-
return config?.executionMode ?? defaultExecutionMode;
|
|
420
|
+
export function resolveSessionExecutionMode(defaultExecutionMode = 'worktree') {
|
|
421
|
+
return defaultExecutionMode;
|
|
439
422
|
}
|
|
440
423
|
function resolveConfiguredDefaultExecutionMode(value) {
|
|
441
424
|
if (value == null || value === '')
|
|
@@ -444,10 +427,10 @@ function resolveConfiguredDefaultExecutionMode(value) {
|
|
|
444
427
|
return value;
|
|
445
428
|
throw new Error('--default-execution-mode must be worktree or locked');
|
|
446
429
|
}
|
|
447
|
-
function resolveWorkspaceCwd(
|
|
448
|
-
return
|
|
430
|
+
function resolveWorkspaceCwd() {
|
|
431
|
+
return resolveConfiguredWorkspaceCwd({
|
|
449
432
|
workspaceOptions,
|
|
450
|
-
|
|
433
|
+
workspaceId: workspaceOptions[0]?.id,
|
|
451
434
|
defaultCwd: workingDir,
|
|
452
435
|
});
|
|
453
436
|
}
|
|
@@ -990,7 +973,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
990
973
|
const turnKey = session.activeInput?.turnKey;
|
|
991
974
|
if (turnKey)
|
|
992
975
|
session.silencedTurnKeys.add(turnKey);
|
|
993
|
-
}, { communication: config.communicationEnabled
|
|
976
|
+
}, { communication: config.communicationEnabled === true }),
|
|
994
977
|
},
|
|
995
978
|
},
|
|
996
979
|
...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
|
|
@@ -1456,6 +1439,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1456
1439
|
deliveryIntent: session.lastAcceptedIntent,
|
|
1457
1440
|
suppressAutoReply: input.suppressAutoReply,
|
|
1458
1441
|
}),
|
|
1442
|
+
...(input.turn.replyAuthority ? { replyAuthority: input.turn.replyAuthority } : {}),
|
|
1459
1443
|
});
|
|
1460
1444
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
1461
1445
|
+ (degraded.length === input.finalText.length
|
|
@@ -1523,6 +1507,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1523
1507
|
messageId: finalMessageId,
|
|
1524
1508
|
...(selfContextId ? { selfContextId } : {}),
|
|
1525
1509
|
metadata,
|
|
1510
|
+
...(deliverTurn.replyAuthority ? { replyAuthority: deliverTurn.replyAuthority } : {}),
|
|
1526
1511
|
}, {}, buildClaudeFinalChunkingOptions(resume));
|
|
1527
1512
|
markFinalTurnDelivered(deliverTurn);
|
|
1528
1513
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars`
|
|
@@ -1585,6 +1570,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1585
1570
|
}
|
|
1586
1571
|
function sendTurnArtifactFile(file) {
|
|
1587
1572
|
return sendMediaFileMessage(client, conversationId, file.path, '', {
|
|
1573
|
+
...(session.activeInput?.replyAuthority
|
|
1574
|
+
? { replyAuthority: session.activeInput.replyAuthority }
|
|
1575
|
+
: {}),
|
|
1588
1576
|
...(session.activeSelfContextId ? { selfContextId: session.activeSelfContextId } : {}),
|
|
1589
1577
|
metadata: buildArtifactMediaMetadata(),
|
|
1590
1578
|
});
|
|
@@ -1663,51 +1651,8 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1663
1651
|
}, FINAL_DELIVERY_RETRY_MS);
|
|
1664
1652
|
}
|
|
1665
1653
|
function writeState() {
|
|
1666
|
-
const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
|
|
1667
|
-
? session.runtimeControlErrors
|
|
1668
|
-
: null;
|
|
1669
|
-
const controlState = {};
|
|
1670
|
-
const appliedAt = Date.now();
|
|
1671
|
-
if (session.state.model !== undefined) {
|
|
1672
|
-
controlState.model = { value: session.state.model, source: 'applied', appliedAt };
|
|
1673
|
-
}
|
|
1674
|
-
else if (config.model !== undefined) {
|
|
1675
|
-
controlState.model = { value: config.model, source: 'requested' };
|
|
1676
|
-
}
|
|
1677
|
-
if (session.state.permissionMode !== undefined) {
|
|
1678
|
-
controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
|
|
1679
|
-
}
|
|
1680
|
-
else if (config.permissionMode !== undefined) {
|
|
1681
|
-
controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
|
|
1682
|
-
}
|
|
1683
|
-
if (session.state.effort !== undefined) {
|
|
1684
|
-
controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
|
|
1685
|
-
}
|
|
1686
|
-
else if (config.effort !== undefined) {
|
|
1687
|
-
controlState.effort = { value: config.effort, source: 'requested' };
|
|
1688
|
-
}
|
|
1689
|
-
if (session.state.ultracode !== undefined) {
|
|
1690
|
-
controlState.ultracode = { value: session.state.ultracode, source: 'applied', appliedAt };
|
|
1691
|
-
}
|
|
1692
|
-
else if (config.ultracode !== undefined) {
|
|
1693
|
-
controlState.ultracode = { value: config.ultracode, source: 'requested' };
|
|
1694
|
-
}
|
|
1695
1654
|
runtimeState.writeSessionState(conversationId, {
|
|
1696
|
-
model: session.state.model,
|
|
1697
|
-
permissionMode: session.state.permissionMode,
|
|
1698
|
-
effort: session.state.effort,
|
|
1699
|
-
...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
|
|
1700
|
-
controlState,
|
|
1701
|
-
runtimeControlErrors,
|
|
1702
1655
|
contextUsage: session.state.contextUsage,
|
|
1703
|
-
...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
|
|
1704
|
-
cwd,
|
|
1705
|
-
executionMode: session.environment.mode,
|
|
1706
|
-
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
1707
|
-
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
1708
|
-
...(resolveExecutionFallbackReason(session.environment)
|
|
1709
|
-
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
1710
|
-
: {}),
|
|
1711
1656
|
hostMode: true,
|
|
1712
1657
|
isActive: true,
|
|
1713
1658
|
}).catch(() => { });
|
|
@@ -2484,13 +2429,13 @@ export async function main() {
|
|
|
2484
2429
|
let agentId;
|
|
2485
2430
|
let ownerId = null;
|
|
2486
2431
|
let ownerName = null;
|
|
2487
|
-
let communicationEnabled =
|
|
2432
|
+
let communicationEnabled = false;
|
|
2488
2433
|
try {
|
|
2489
2434
|
const ctx = await client.getAgentMe();
|
|
2490
2435
|
agentId = ctx.agentId;
|
|
2491
2436
|
ownerId = ctx.ownerId;
|
|
2492
2437
|
ownerName = ctx.ownerName;
|
|
2493
|
-
communicationEnabled = ctx.outboundPolicy
|
|
2438
|
+
communicationEnabled = isProactiveCommunicationEnabled(ctx.outboundPolicy);
|
|
2494
2439
|
console.error(`[canon-host] Connected as ${ctx.displayName || agentId}`);
|
|
2495
2440
|
}
|
|
2496
2441
|
catch {
|
|
@@ -2580,25 +2525,6 @@ export async function main() {
|
|
|
2580
2525
|
agentId,
|
|
2581
2526
|
rtdb,
|
|
2582
2527
|
clientType: 'claude-code',
|
|
2583
|
-
runtime: runtimeDescriptor,
|
|
2584
|
-
workspaceOptions,
|
|
2585
|
-
defaultCwd: workingDir,
|
|
2586
|
-
extraSessionConfigFields: ['permissionMode', 'effort'],
|
|
2587
|
-
liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
|
|
2588
|
-
const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
|
|
2589
|
-
return [
|
|
2590
|
-
session.conversationId,
|
|
2591
|
-
{
|
|
2592
|
-
...(session.state.model ? { model: session.state.model } : {}),
|
|
2593
|
-
...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
|
|
2594
|
-
...(session.state.effort ? { effort: session.state.effort } : {}),
|
|
2595
|
-
...(session.state.ultracode ? { runtimeControlValues: { ultracode: session.state.ultracode } } : {}),
|
|
2596
|
-
...(workspaceId ? { workspaceId } : {}),
|
|
2597
|
-
executionMode: session.environment.mode,
|
|
2598
|
-
executionBranch: session.environment.branch ?? null,
|
|
2599
|
-
},
|
|
2600
|
-
];
|
|
2601
|
-
})),
|
|
2602
2528
|
}).catch((error) => {
|
|
2603
2529
|
console.error('[canon-host] Failed to publish session snapshots:', error);
|
|
2604
2530
|
});
|
|
@@ -2742,8 +2668,8 @@ export async function main() {
|
|
|
2742
2668
|
// input descriptor via `request()` (see the AskUserQuestion canUseTool branch).
|
|
2743
2669
|
const runtimeInputManager = new RuntimeRequestManager(client, { agentId, ownerId: ownerId ?? '' });
|
|
2744
2670
|
const pendingSessionCreations = new Map();
|
|
2745
|
-
// Shared /control channel poller (claude host profile):
|
|
2746
|
-
//
|
|
2671
|
+
// Shared /control channel poller (claude host profile): signal keys,
|
|
2672
|
+
// sequential conversations, active/idle cadence + jitter sampled at
|
|
2747
2673
|
// cycle start, eager baselines at session creation, and nodes left in place
|
|
2748
2674
|
// when a handler throws (consumeOnError defaults to false for both keys).
|
|
2749
2675
|
const controlPoller = new ControlChannelPoller({
|
|
@@ -2765,7 +2691,6 @@ export async function main() {
|
|
|
2765
2691
|
pollOnStart: true,
|
|
2766
2692
|
conversationConcurrency: 'sequential',
|
|
2767
2693
|
handlers: {
|
|
2768
|
-
session: { handle: applyRuntimeSessionControl },
|
|
2769
2694
|
signal: { handle: handleRuntimeControlSignal },
|
|
2770
2695
|
},
|
|
2771
2696
|
onError: ({ scope, key, conversationId, error }) => {
|
|
@@ -3000,22 +2925,47 @@ export async function main() {
|
|
|
3000
2925
|
evictOldestIdle();
|
|
3001
2926
|
}
|
|
3002
2927
|
const creation = (async () => {
|
|
3003
|
-
const
|
|
3004
|
-
const
|
|
3005
|
-
|
|
3006
|
-
const environment = prepareConversationEnvironment({
|
|
2928
|
+
const sessionExecutionMode = resolveSessionExecutionMode(defaultExecutionMode);
|
|
2929
|
+
const workspaceCwd = resolveWorkspaceCwd();
|
|
2930
|
+
let environment = prepareConversationEnvironment({
|
|
3007
2931
|
agentId,
|
|
3008
2932
|
conversationId,
|
|
3009
2933
|
workspaceCwd,
|
|
3010
2934
|
allowWorktrees: sessionExecutionMode === 'worktree',
|
|
3011
2935
|
});
|
|
3012
2936
|
try {
|
|
3013
|
-
const
|
|
3014
|
-
const persisted = loadRuntimeSessionState(runtimeId, {
|
|
2937
|
+
const persistedMapping = resolveLocalRuntimeSessionState(runtimeId, {
|
|
3015
2938
|
conversationId,
|
|
3016
2939
|
baseCwd: environment.baseCwd,
|
|
3017
2940
|
executionMode: environment.mode,
|
|
2941
|
+
resumeField: 'claudeSessionId',
|
|
2942
|
+
configuredBaseCwds: workspaceOptions.map((workspace) => workspace.cwd),
|
|
2943
|
+
availableExecutionModes: hostAvailableExecutionModes,
|
|
3018
2944
|
});
|
|
2945
|
+
if (persistedMapping.status === 'configuration_required') {
|
|
2946
|
+
throw new ExecutionEnvironmentError(persistedMapping.message, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
2947
|
+
}
|
|
2948
|
+
if (persistedMapping.status === 'adopted'
|
|
2949
|
+
&& (persistedMapping.state.baseCwd !== environment.baseCwd
|
|
2950
|
+
|| persistedMapping.state.executionMode !== environment.mode)) {
|
|
2951
|
+
const restoredEnvironment = prepareConversationEnvironment({
|
|
2952
|
+
agentId,
|
|
2953
|
+
conversationId,
|
|
2954
|
+
workspaceCwd: persistedMapping.state.baseCwd,
|
|
2955
|
+
allowWorktrees: persistedMapping.state.executionMode === 'worktree',
|
|
2956
|
+
});
|
|
2957
|
+
if (restoredEnvironment.mode !== persistedMapping.state.executionMode) {
|
|
2958
|
+
releaseConversationEnvironment(restoredEnvironment);
|
|
2959
|
+
throw new ExecutionEnvironmentError(`Conversation ${conversationId} requires local execution mode ${persistedMapping.state.executionMode}, but workspace ${persistedMapping.state.baseCwd} can only be opened in ${restoredEnvironment.mode} mode.`, LOCAL_CONFIGURATION_REQUIRED_MESSAGE);
|
|
2960
|
+
}
|
|
2961
|
+
releaseConversationEnvironment(environment);
|
|
2962
|
+
environment = restoredEnvironment;
|
|
2963
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Restoring saved local runtime → ${environment.mode} (${environment.baseCwd})`);
|
|
2964
|
+
}
|
|
2965
|
+
const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
|
|
2966
|
+
const persisted = persistedMapping.status === 'new'
|
|
2967
|
+
? null
|
|
2968
|
+
: persistedMapping.state;
|
|
3019
2969
|
const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
|
|
3020
2970
|
const session = createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, (convoId, sdkSessionId) => {
|
|
3021
2971
|
// Called when session processing loop ends — save session ID for resume
|
|
@@ -3056,10 +3006,10 @@ export async function main() {
|
|
|
3056
3006
|
runtimeDescriptor = descriptor;
|
|
3057
3007
|
void publishRuntimeHeartbeat();
|
|
3058
3008
|
}, {
|
|
3059
|
-
model:
|
|
3060
|
-
permissionMode:
|
|
3061
|
-
effort:
|
|
3062
|
-
ultracode:
|
|
3009
|
+
model: runtimeModels[0]?.value,
|
|
3010
|
+
permissionMode: 'default',
|
|
3011
|
+
effort: undefined,
|
|
3012
|
+
ultracode: undefined,
|
|
3063
3013
|
availableModels: runtimeModels,
|
|
3064
3014
|
availableExecutionModes: hostAvailableExecutionModes,
|
|
3065
3015
|
defaultExecutionMode,
|
|
@@ -3078,6 +3028,10 @@ export async function main() {
|
|
|
3078
3028
|
}, resumeId);
|
|
3079
3029
|
sessions.set(conversationId, session);
|
|
3080
3030
|
await controlPoller.baseline([conversationId]);
|
|
3031
|
+
await runtimeState.patchAgentSessionSnapshot(conversationId, {
|
|
3032
|
+
configurationStatus: 'ready',
|
|
3033
|
+
lastError: null,
|
|
3034
|
+
});
|
|
3081
3035
|
return session;
|
|
3082
3036
|
}
|
|
3083
3037
|
catch (error) {
|
|
@@ -3163,13 +3117,17 @@ export async function main() {
|
|
|
3163
3117
|
}
|
|
3164
3118
|
catch (error) {
|
|
3165
3119
|
const message = error instanceof Error ? error.message : String(error);
|
|
3166
|
-
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
3167
3120
|
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
|
|
3121
|
+
await runtimeState.patchAgentSessionSnapshot(input.conversationId, {
|
|
3122
|
+
configurationStatus: 'configuration_required',
|
|
3123
|
+
lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
|
|
3124
|
+
}).catch(() => { });
|
|
3168
3125
|
await markQueuedMessageAcceptedForConversation(input.conversationId, m.id ?? null, shouldMarkAccepted);
|
|
3169
|
-
await sendMessageWithRetry(client, input.conversationId,
|
|
3126
|
+
await sendMessageWithRetry(client, input.conversationId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
|
|
3170
3127
|
messageId: `claude-start-failed-${m.id}`,
|
|
3171
3128
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
3172
3129
|
metadata: {
|
|
3130
|
+
runtimeStatus: 'configuration_required',
|
|
3173
3131
|
turnSemantics: 'turn_complete',
|
|
3174
3132
|
replyBehavior: 'suppress_auto_reply',
|
|
3175
3133
|
},
|
|
@@ -3426,16 +3384,22 @@ export async function main() {
|
|
|
3426
3384
|
}
|
|
3427
3385
|
catch (error) {
|
|
3428
3386
|
const message = error instanceof Error ? error.message : String(error);
|
|
3429
|
-
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
3430
3387
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
3388
|
+
await runtimeState.patchAgentSessionSnapshot(convoId, {
|
|
3389
|
+
configurationStatus: 'configuration_required',
|
|
3390
|
+
lastError: LOCAL_CONFIGURATION_REQUIRED_MESSAGE,
|
|
3391
|
+
}).catch(() => { });
|
|
3431
3392
|
await markQueuedMessageAcceptedForConversation(convoId, m.id ?? null, shouldMarkAccepted);
|
|
3432
|
-
await sendMessageWithRetry(client, convoId,
|
|
3393
|
+
await sendMessageWithRetry(client, convoId, LOCAL_CONFIGURATION_REQUIRED_MESSAGE, {
|
|
3433
3394
|
messageId: `claude-start-failed-${m.id}`,
|
|
3434
3395
|
...(activeSelfContextId ? { selfContextId: activeSelfContextId } : {}),
|
|
3435
3396
|
metadata: {
|
|
3397
|
+
turnId: `claude-start:${m.id}`,
|
|
3398
|
+
runtimeStatus: 'configuration_required',
|
|
3436
3399
|
turnSemantics: 'turn_complete',
|
|
3437
3400
|
replyBehavior: 'suppress_auto_reply',
|
|
3438
3401
|
},
|
|
3402
|
+
...(payload.replyAuthority ? { replyAuthority: payload.replyAuthority } : {}),
|
|
3439
3403
|
}).catch(() => { });
|
|
3440
3404
|
return false;
|
|
3441
3405
|
}
|
|
@@ -3455,7 +3419,10 @@ export async function main() {
|
|
|
3455
3419
|
promptText,
|
|
3456
3420
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
3457
3421
|
});
|
|
3458
|
-
const turnModes =
|
|
3422
|
+
const turnModes = {
|
|
3423
|
+
...resolveClaudeTurnModes(participantContext),
|
|
3424
|
+
replyAuthority: payload.replyAuthority ?? null,
|
|
3425
|
+
};
|
|
3459
3426
|
session.enqueueInbound({
|
|
3460
3427
|
type: 'user',
|
|
3461
3428
|
message: {
|
|
@@ -3513,39 +3480,6 @@ export async function main() {
|
|
|
3513
3480
|
stream.start().catch((err) => {
|
|
3514
3481
|
console.error('[canon-host] SSE start error:', err instanceof Error ? err.message : err);
|
|
3515
3482
|
});
|
|
3516
|
-
// ── Control signal watcher ──
|
|
3517
|
-
// Session control signals (model, mode, effort). A missing/closed session
|
|
3518
|
-
// leaves the node in place; lastSeen still advances, so a stale control is
|
|
3519
|
-
// never replayed once a session exists.
|
|
3520
|
-
async function applyRuntimeSessionControl(event) {
|
|
3521
|
-
const convoId = event.conversationId;
|
|
3522
|
-
const session = sessions.get(convoId);
|
|
3523
|
-
if (!session || session.closed)
|
|
3524
|
-
return { consume: false };
|
|
3525
|
-
const ctrl = event.control;
|
|
3526
|
-
console.error(`[canon-host] [${convoId.slice(0, 8)}] Control:`, ctrl);
|
|
3527
|
-
await applyClaudeSessionControl({
|
|
3528
|
-
model: ctrl.model,
|
|
3529
|
-
permissionMode: ctrl.permissionMode,
|
|
3530
|
-
effort: ctrl.effort,
|
|
3531
|
-
ultracode: ctrl.runtimeControlValues?.ultracode,
|
|
3532
|
-
}, {
|
|
3533
|
-
state: session.state,
|
|
3534
|
-
parsePermissionMode: parseClaudePermissionMode,
|
|
3535
|
-
normalizeEffort: normalizeClaudeEffortLevel,
|
|
3536
|
-
parseUltracode: parseClaudeUltracodeMode,
|
|
3537
|
-
setModel: (model) => session.query.setModel(model),
|
|
3538
|
-
setPermissionMode: (mode) => session.query.setPermissionMode(mode),
|
|
3539
|
-
applyEffort: (level) => applyClaudeEffortLevel(session.query, level),
|
|
3540
|
-
applyUltracode: (enabled) => applyClaudeUltracode(session.query, enabled),
|
|
3541
|
-
setRuntimeControlError: (controlId, value, error) => session.setRuntimeControlError(controlId, value, error),
|
|
3542
|
-
clearRuntimeControlError: (controlId) => session.clearRuntimeControlError(controlId),
|
|
3543
|
-
// Publish the app-subscribed snapshot immediately so the control-pending
|
|
3544
|
-
// gate clears now instead of at the next 30s heartbeat (lag fix).
|
|
3545
|
-
publishSnapshot: () => { void publishSessionSnapshots([convoId]); },
|
|
3546
|
-
});
|
|
3547
|
-
session.writeState();
|
|
3548
|
-
}
|
|
3549
3483
|
// Stop/new-session signals remain pending until the Claude SDK confirms the
|
|
3550
3484
|
// interrupt. `defer` deliberately makes the poller revisit the same node.
|
|
3551
3485
|
async function handleRuntimeControlSignal(event) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AgentReplyAuthorityV1 } from '@canonmsg/backend-contracts';
|
|
2
|
+
export interface InboundReplySource {
|
|
3
|
+
conversationId: string;
|
|
4
|
+
sourceMessageId: string;
|
|
5
|
+
replyAuthority?: AgentReplyAuthorityV1;
|
|
6
|
+
}
|
|
7
|
+
export interface PendingInboundReplySource extends InboundReplySource {
|
|
8
|
+
readonly generation: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class InboundReplyAuthorityError extends Error {
|
|
11
|
+
constructor();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One-shot authority for Claude channel replies.
|
|
15
|
+
*
|
|
16
|
+
* Every inbound message first supersedes the previous source. The caller then
|
|
17
|
+
* activates that exact source only after deciding the message is a real Claude
|
|
18
|
+
* turn (rather than, for example, an approval response consumed locally).
|
|
19
|
+
*/
|
|
20
|
+
export declare class InboundReplyAuthority {
|
|
21
|
+
private readonly ttlMs;
|
|
22
|
+
private readonly now;
|
|
23
|
+
private generation;
|
|
24
|
+
private active;
|
|
25
|
+
constructor(ttlMs?: number, now?: () => number);
|
|
26
|
+
/** Supersede any prior authority before filtering the new inbound message. */
|
|
27
|
+
beginInbound(source: InboundReplySource): PendingInboundReplySource;
|
|
28
|
+
/** Activate a pending source only if no newer inbound has superseded it. */
|
|
29
|
+
authorize(source: PendingInboundReplySource): boolean;
|
|
30
|
+
/** Revoke an exact source without disturbing a newer inbound message. */
|
|
31
|
+
revoke(source: PendingInboundReplySource): void;
|
|
32
|
+
/** Validate a non-final in-turn action without consuming reply authority. */
|
|
33
|
+
validate(source: InboundReplySource): boolean;
|
|
34
|
+
/** Consume the current source when the model deliberately chooses silence. */
|
|
35
|
+
consumeNoReply(source: InboundReplySource): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Atomically claim one visible reply. A failed operation restores the claim
|
|
38
|
+
* only while the same inbound is still current and unexpired.
|
|
39
|
+
*/
|
|
40
|
+
sendVisibleReply<T>(source: InboundReplySource, operation: (replyAuthority: AgentReplyAuthorityV1 | undefined) => Promise<T>): Promise<T>;
|
|
41
|
+
private getLiveActive;
|
|
42
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const DEFAULT_REPLY_AUTHORITY_TTL_MS = 15 * 60 * 1000;
|
|
2
|
+
export class InboundReplyAuthorityError extends Error {
|
|
3
|
+
constructor() {
|
|
4
|
+
super('This reply is not authorized for the current inbound Canon message.');
|
|
5
|
+
this.name = 'InboundReplyAuthorityError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function validSource(source) {
|
|
9
|
+
return source.conversationId.length > 0 && source.sourceMessageId.length > 0;
|
|
10
|
+
}
|
|
11
|
+
function sameSource(a, b) {
|
|
12
|
+
return a.conversationId === b.conversationId
|
|
13
|
+
&& a.sourceMessageId === b.sourceMessageId;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One-shot authority for Claude channel replies.
|
|
17
|
+
*
|
|
18
|
+
* Every inbound message first supersedes the previous source. The caller then
|
|
19
|
+
* activates that exact source only after deciding the message is a real Claude
|
|
20
|
+
* turn (rather than, for example, an approval response consumed locally).
|
|
21
|
+
*/
|
|
22
|
+
export class InboundReplyAuthority {
|
|
23
|
+
ttlMs;
|
|
24
|
+
now;
|
|
25
|
+
generation = 0;
|
|
26
|
+
active = null;
|
|
27
|
+
constructor(ttlMs = DEFAULT_REPLY_AUTHORITY_TTL_MS, now = Date.now) {
|
|
28
|
+
this.ttlMs = ttlMs;
|
|
29
|
+
this.now = now;
|
|
30
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
31
|
+
throw new TypeError('Reply authority TTL must be positive.');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Supersede any prior authority before filtering the new inbound message. */
|
|
35
|
+
beginInbound(source) {
|
|
36
|
+
if (!validSource(source)) {
|
|
37
|
+
throw new TypeError('Inbound reply authority requires a conversation and source message.');
|
|
38
|
+
}
|
|
39
|
+
this.generation += 1;
|
|
40
|
+
this.active = null;
|
|
41
|
+
return { ...source, generation: this.generation };
|
|
42
|
+
}
|
|
43
|
+
/** Activate a pending source only if no newer inbound has superseded it. */
|
|
44
|
+
authorize(source) {
|
|
45
|
+
if (source.generation !== this.generation)
|
|
46
|
+
return false;
|
|
47
|
+
this.active = {
|
|
48
|
+
...source,
|
|
49
|
+
expiresAt: this.now() + this.ttlMs,
|
|
50
|
+
claimed: false,
|
|
51
|
+
};
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
/** Revoke an exact source without disturbing a newer inbound message. */
|
|
55
|
+
revoke(source) {
|
|
56
|
+
if (this.active?.generation === source.generation && sameSource(this.active, source)) {
|
|
57
|
+
this.active = null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** Validate a non-final in-turn action without consuming reply authority. */
|
|
61
|
+
validate(source) {
|
|
62
|
+
const active = this.getLiveActive();
|
|
63
|
+
return active !== null && sameSource(active, source);
|
|
64
|
+
}
|
|
65
|
+
/** Consume the current source when the model deliberately chooses silence. */
|
|
66
|
+
consumeNoReply(source) {
|
|
67
|
+
const active = this.getLiveActive();
|
|
68
|
+
if (!active || active.claimed || !sameSource(active, source))
|
|
69
|
+
return false;
|
|
70
|
+
this.active = null;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Atomically claim one visible reply. A failed operation restores the claim
|
|
75
|
+
* only while the same inbound is still current and unexpired.
|
|
76
|
+
*/
|
|
77
|
+
async sendVisibleReply(source, operation) {
|
|
78
|
+
const active = this.getLiveActive();
|
|
79
|
+
if (!active || active.claimed || !sameSource(active, source)) {
|
|
80
|
+
throw new InboundReplyAuthorityError();
|
|
81
|
+
}
|
|
82
|
+
active.claimed = true;
|
|
83
|
+
try {
|
|
84
|
+
const result = await operation(active.replyAuthority);
|
|
85
|
+
if (this.active === active)
|
|
86
|
+
this.active = null;
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (this.active === active) {
|
|
91
|
+
if (this.now() < active.expiresAt) {
|
|
92
|
+
active.claimed = false;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.active = null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
getLiveActive() {
|
|
102
|
+
if (this.active && this.now() >= this.active.expiresAt) {
|
|
103
|
+
this.active = null;
|
|
104
|
+
}
|
|
105
|
+
return this.active;
|
|
106
|
+
}
|
|
107
|
+
}
|