@canonmsg/claude-code-plugin 0.31.3 → 0.32.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/README.md +5 -0
- package/dist/canon-user-content.d.ts +3 -3
- package/dist/canon-user-content.js +4 -4
- package/dist/host.d.ts +3 -0
- package/dist/host.js +194 -57
- package/dist/register.js +1 -0
- package/dist/server.js +6 -2
- package/dist/session-state.d.ts +24 -0
- package/dist/session-state.js +33 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -89,6 +89,11 @@ canon-claude --cwd /path/to/project --turn-verbosity quiet
|
|
|
89
89
|
| `verbose` | Live streaming text plus the margin activity rows on the final, everywhere |
|
|
90
90
|
| `quiet` | The thinking indicator and the answer, nothing in between, everywhere |
|
|
91
91
|
|
|
92
|
+
In verbose mode, authored text remains continual across tool use: text before a
|
|
93
|
+
tool stays as its own speech bubble, the tool appears in the activity margin,
|
|
94
|
+
and text after the result resumes in a new bubble. These are ephemeral streaming
|
|
95
|
+
updates; only the final durable `turn_complete` message is notification-eligible.
|
|
96
|
+
|
|
92
97
|
Quiet drops the live `/streaming` narration and the final's `turnTrail` activity rows. It does **not** drop the thinking indicator (which now stays up for the turn's whole working phase rather than handing over to a bubble that never appears; while the turn is parked on an approval the clients suppress an agent's dots and the header line carries the state), the turn state, the answer — including every part of a long chunked one — failure notices, generated files, or approval and question cards and their receipts.
|
|
93
98
|
|
|
94
99
|
This is an agent-developer setting. Canon never changes it, and it is deliberately not part of the per-conversation session config a user can edit. `canon-necromance` replays a stored launch command verbatim, so add the flag at registration time if you want a non-default value.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import { type AnthropicImageBudgetOptions, type MaterializedCanonAttachment } from '@canonmsg/agent-sdk';
|
|
3
|
-
import { renderCanonHostInboundContent, type CanonReplyContext } from '@canonmsg/core';
|
|
3
|
+
import { renderCanonHostInboundContent, type CanonReplyContext, type HostInboundContentRenderOptions } from '@canonmsg/core';
|
|
4
4
|
type ClaudeRenderableInboundMessage = Parameters<typeof renderCanonHostInboundContent>[0];
|
|
5
5
|
/**
|
|
6
6
|
* Claude-only rendering policy for Canon media. Core deliberately omits all
|
|
@@ -8,8 +8,8 @@ type ClaudeRenderableInboundMessage = Parameters<typeof renderCanonHostInboundCo
|
|
|
8
8
|
* that the bounded runtime did not materialize, while materialized images keep
|
|
9
9
|
* their local-path placeholder without a duplicate link.
|
|
10
10
|
*/
|
|
11
|
-
export declare function renderClaudeInboundContent(message: ClaudeRenderableInboundMessage, materialized?: ReadonlyArray<MaterializedCanonAttachment
|
|
12
|
-
export declare function withClaudeReplyContextMediaReferences(replyContext: CanonReplyContext | null, materialized: ReadonlyArray<MaterializedCanonAttachment
|
|
11
|
+
export declare function renderClaudeInboundContent(message: ClaudeRenderableInboundMessage, materialized?: ReadonlyArray<MaterializedCanonAttachment>, renderOptions?: HostInboundContentRenderOptions): string;
|
|
12
|
+
export declare function withClaudeReplyContextMediaReferences(replyContext: CanonReplyContext | null, materialized: ReadonlyArray<MaterializedCanonAttachment>, renderOptions?: HostInboundContentRenderOptions): CanonReplyContext | null;
|
|
13
13
|
/**
|
|
14
14
|
* Build the Claude Code SDK's multimodal user content without allowing native
|
|
15
15
|
* image blocks to consume the whole request. The prompt text is preserved
|
|
@@ -28,8 +28,8 @@ function validatedPromptAttachmentUrl(value) {
|
|
|
28
28
|
* that the bounded runtime did not materialize, while materialized images keep
|
|
29
29
|
* their local-path placeholder without a duplicate link.
|
|
30
30
|
*/
|
|
31
|
-
export function renderClaudeInboundContent(message, materialized = []) {
|
|
32
|
-
const rendered = renderCanonHostInboundContent(message, materialized);
|
|
31
|
+
export function renderClaudeInboundContent(message, materialized = [], renderOptions = {}) {
|
|
32
|
+
const rendered = renderCanonHostInboundContent(message, materialized, renderOptions);
|
|
33
33
|
const materializedIndexes = new Set(materialized.map((attachment) => attachment.index));
|
|
34
34
|
const imageLinks = (message.attachments ?? []).flatMap((attachment, index) => {
|
|
35
35
|
if (attachment.kind !== 'image' || materializedIndexes.has(index))
|
|
@@ -39,7 +39,7 @@ export function renderClaudeInboundContent(message, materialized = []) {
|
|
|
39
39
|
});
|
|
40
40
|
return imageLinks.length > 0 ? `${rendered}\n${imageLinks.join('\n')}` : rendered;
|
|
41
41
|
}
|
|
42
|
-
export function withClaudeReplyContextMediaReferences(replyContext, materialized) {
|
|
42
|
+
export function withClaudeReplyContextMediaReferences(replyContext, materialized, renderOptions = {}) {
|
|
43
43
|
if (!replyContext?.found)
|
|
44
44
|
return replyContext;
|
|
45
45
|
return {
|
|
@@ -50,7 +50,7 @@ export function withClaudeReplyContextMediaReferences(replyContext, materialized
|
|
|
50
50
|
attachments: replyContext.attachments,
|
|
51
51
|
contactCard: replyContext.contactCard,
|
|
52
52
|
senderType: replyContext.senderType ?? undefined,
|
|
53
|
-
}, materialized),
|
|
53
|
+
}, materialized, renderOptions),
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
/**
|
package/dist/host.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type PermissionResult, type SDKControlReloadPluginsResponse } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
+
import { type RecoveryCheckpointTracker } from '@canonmsg/coding-agent-host';
|
|
3
4
|
import { type CanonRuntimeCommandDescriptor, type HostInboundParticipantContext as InboundParticipantContext, type CanonReplyContext, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type TurnVerbosityConfig } from '@canonmsg/core';
|
|
4
5
|
/**
|
|
5
6
|
* `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
|
|
@@ -24,7 +25,9 @@ export declare function buildClaudePlanAllowResult(input: Record<string, unknown
|
|
|
24
25
|
* through. Canon-injected aliases keep precedence over native names.
|
|
25
26
|
*/
|
|
26
27
|
export declare function buildNativeSlashCommandDescriptors(native: SDKControlReloadPluginsResponse['commands'], reservedAliases: ReadonlySet<string>): CanonRuntimeCommandDescriptor[];
|
|
28
|
+
export declare function createClaudeRecoveryCheckpointTracker(persist: (messageId: string) => boolean): RecoveryCheckpointTracker;
|
|
27
29
|
export declare const NO_REPLY_TOOL_NAME: string;
|
|
30
|
+
export declare const REACH_OUT_TOOL_NAME = "mcp__canon__send_to";
|
|
28
31
|
export declare function buildCanonPrompt(input: {
|
|
29
32
|
content: string;
|
|
30
33
|
conversationId: string;
|
package/dist/host.js
CHANGED
|
@@ -27,14 +27,14 @@ import { existsSync } from 'node:fs';
|
|
|
27
27
|
import { readFile } from 'node:fs/promises';
|
|
28
28
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
29
29
|
import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
30
|
-
import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, collectMissedInboundMessages,
|
|
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, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
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, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, formatPendingContactLifecycleContext, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, parseTurnVerbosityConfig, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, readLocalRuntimeEntry, reconcileContactLifecycleEvents, recordLocalRuntimeContactLifecycleEvent, saveLocalRuntimeContactLifecycleCursor, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, takeLocalRuntimeContactLifecycleEvents, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
33
33
|
import { runCli } from '@canonmsg/core';
|
|
34
|
-
import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
|
|
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 { applyClaudeSessionControl, boundClaudeFinalMetadata, buildClaudeFinalChunkingOptions, buildClaudeFinalMessageId, buildClaudeFinalTurnMetadata, buildClaudePendingFinalDelivery, buildClaudeTurnFailureNotice, buildTruncatedFinalText, buildUndeliverableFinalNotice, canDrainClaudeQueuedInput, claudeFinalWillChunk, classifyFinalDeliveryFailure, claudeInputOwnsTurnSlot, createClaudeTurnActivityState, decideClaudeControlSignalAction, decideClaudeInboundDispatch, dispatchClaudeInput, 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';
|
|
37
|
+
import { applyClaudeSessionControl, 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
39
|
function parseRuntimeVisibilityPreset(value) {
|
|
40
40
|
return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
|
|
@@ -303,7 +303,11 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
303
303
|
activation: { kind: 'control', controlId: 'permissionMode', value: 'plan' },
|
|
304
304
|
},
|
|
305
305
|
],
|
|
306
|
-
admissionActions:
|
|
306
|
+
admissionActions: {
|
|
307
|
+
...HOST_ADMISSION_ACTIONS_DISABLED,
|
|
308
|
+
requestContact: true,
|
|
309
|
+
reachOut: true,
|
|
310
|
+
},
|
|
307
311
|
presentation: input.presentation,
|
|
308
312
|
commands,
|
|
309
313
|
});
|
|
@@ -421,6 +425,7 @@ async function loadSessionConfig(conversationId, agentId, rtdb) {
|
|
|
421
425
|
agentId,
|
|
422
426
|
rtdb,
|
|
423
427
|
extraStringFields: ['permissionMode', 'effort'],
|
|
428
|
+
retryMissingMs: 3_000,
|
|
424
429
|
});
|
|
425
430
|
return {
|
|
426
431
|
...config,
|
|
@@ -728,6 +733,9 @@ const FINAL_DELIVERY_RETRY_MS = 30_000;
|
|
|
728
733
|
// without delaying anything that was going to fail anyway.
|
|
729
734
|
const MAX_FINAL_DELIVERY_RETRIES = 2;
|
|
730
735
|
const CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID = 'claude-recovery-cursor-v1';
|
|
736
|
+
export function createClaudeRecoveryCheckpointTracker(persist) {
|
|
737
|
+
return createRecoveryCheckpointTracker(persist);
|
|
738
|
+
}
|
|
731
739
|
const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
732
740
|
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
733
741
|
supportsInterrupt: true,
|
|
@@ -749,6 +757,16 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
|
749
757
|
*/
|
|
750
758
|
const NO_REPLY_VERB = 'no_reply';
|
|
751
759
|
export const NO_REPLY_TOOL_NAME = `mcp__${CANON_VERB_MCP_SERVER_NAME}__${NO_REPLY_VERB}`;
|
|
760
|
+
export const REACH_OUT_TOOL_NAME = `mcp__${CANON_VERB_MCP_SERVER_NAME}__send_to`;
|
|
761
|
+
function ownerBoundRenderOptions(isOwner) {
|
|
762
|
+
const admissionActions = isOwner
|
|
763
|
+
? { ...HOST_ADMISSION_ACTIONS_DISABLED, requestContact: true, reachOut: true }
|
|
764
|
+
: HOST_ADMISSION_ACTIONS_DISABLED;
|
|
765
|
+
return {
|
|
766
|
+
admissionActions,
|
|
767
|
+
...(admissionActions.reachOut ? { reachOutToolName: REACH_OUT_TOOL_NAME } : {}),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
752
770
|
export function buildCanonPrompt(input) {
|
|
753
771
|
return renderCodingHostInboundPrompt(buildCanonInboundFrameV1(buildCanonTurnContextV2({
|
|
754
772
|
content: input.content,
|
|
@@ -781,8 +799,8 @@ function resolveClaudeTurnModes(participantContext) {
|
|
|
781
799
|
}),
|
|
782
800
|
};
|
|
783
801
|
}
|
|
784
|
-
function renderInboundContent(message, materialized) {
|
|
785
|
-
return renderClaudeInboundContent(message, materialized);
|
|
802
|
+
function renderInboundContent(message, materialized, renderOptions = {}) {
|
|
803
|
+
return renderClaudeInboundContent(message, materialized, renderOptions);
|
|
786
804
|
}
|
|
787
805
|
async function materializePromptReplyContext(input) {
|
|
788
806
|
if (!input.replyContext?.found || !input.replyContext.attachments?.length) {
|
|
@@ -795,17 +813,27 @@ async function materializePromptReplyContext(input) {
|
|
|
795
813
|
});
|
|
796
814
|
return {
|
|
797
815
|
...result,
|
|
798
|
-
replyContext: withClaudeReplyContextMediaReferences(result.replyContext, result.materialized),
|
|
816
|
+
replyContext: withClaudeReplyContextMediaReferences(result.replyContext, result.materialized, input.renderOptions),
|
|
799
817
|
};
|
|
800
818
|
}
|
|
801
819
|
catch (error) {
|
|
802
820
|
console.error(`${input.logPrefix} Failed to materialize replied-to media:`, error instanceof Error ? error.message : error);
|
|
803
821
|
return {
|
|
804
|
-
replyContext: withClaudeReplyContextMediaReferences(input.replyContext, []),
|
|
822
|
+
replyContext: withClaudeReplyContextMediaReferences(input.replyContext, [], input.renderOptions),
|
|
805
823
|
materialized: [],
|
|
806
824
|
};
|
|
807
825
|
}
|
|
808
826
|
}
|
|
827
|
+
function ownerBoundReplyContactTarget(replyContext) {
|
|
828
|
+
const card = replyContext?.found ? replyContext.contactCard : undefined;
|
|
829
|
+
if (!card?.userId)
|
|
830
|
+
return undefined;
|
|
831
|
+
return {
|
|
832
|
+
targetUserId: card.userId,
|
|
833
|
+
...(card.canonContactId ? { canonContactId: card.canonContactId } : {}),
|
|
834
|
+
sourceCardMessageId: replyContext.messageId,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
809
837
|
// ── Session factory ─────────────────────────────────────────────────
|
|
810
838
|
function createSession(conversationId, environment, agentId, client, typingSignals, runtimeState, onSessionEnd, onRuntimeDescriptorUpdate, config, resumeSessionId) {
|
|
811
839
|
const { cwd } = environment;
|
|
@@ -824,7 +852,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
824
852
|
messageQueue.push(input);
|
|
825
853
|
}
|
|
826
854
|
};
|
|
827
|
-
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
|
|
855
|
+
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => {
|
|
828
856
|
sendInput(createClaudeInputEnvelope({
|
|
829
857
|
kind: 'canon',
|
|
830
858
|
msg,
|
|
@@ -833,6 +861,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
833
861
|
markAccepted,
|
|
834
862
|
isOwnerTurn,
|
|
835
863
|
requestingUserId,
|
|
864
|
+
replyContactTarget,
|
|
836
865
|
...turnModes,
|
|
837
866
|
}));
|
|
838
867
|
};
|
|
@@ -979,6 +1008,23 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
979
1008
|
const turnKey = session.activeInput?.turnKey;
|
|
980
1009
|
if (turnKey)
|
|
981
1010
|
session.silencedTurnKeys.add(turnKey);
|
|
1011
|
+
}, {
|
|
1012
|
+
ownerBoundCommunication: {
|
|
1013
|
+
getContext: () => {
|
|
1014
|
+
const input = session.activeInput;
|
|
1015
|
+
if (!input?.isOwnerTurn || !input.sourceMessageId)
|
|
1016
|
+
return null;
|
|
1017
|
+
return {
|
|
1018
|
+
isOwnerTurn: true,
|
|
1019
|
+
conversationId,
|
|
1020
|
+
sourceMessageId: input.sourceMessageId,
|
|
1021
|
+
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
1022
|
+
...(input.replyContactTarget
|
|
1023
|
+
? { replyContactTarget: input.replyContactTarget }
|
|
1024
|
+
: {}),
|
|
1025
|
+
};
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
982
1028
|
}),
|
|
983
1029
|
},
|
|
984
1030
|
},
|
|
@@ -1168,7 +1214,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1168
1214
|
environment,
|
|
1169
1215
|
query: q,
|
|
1170
1216
|
sendInput,
|
|
1171
|
-
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes),
|
|
1217
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes, replyContactTarget),
|
|
1172
1218
|
state: {
|
|
1173
1219
|
model: undefined,
|
|
1174
1220
|
permissionMode: config.permissionMode,
|
|
@@ -1260,19 +1306,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1260
1306
|
function markInputCompleted(input) {
|
|
1261
1307
|
if (input?.kind !== 'canon' || !input.sourceMessageId)
|
|
1262
1308
|
return;
|
|
1263
|
-
if (config.runtimeId) {
|
|
1264
|
-
try {
|
|
1265
|
-
saveRuntimeSessionState(config.runtimeId, {
|
|
1266
|
-
conversationId,
|
|
1267
|
-
baseCwd: config.recoveryBaseCwd ?? environment.baseCwd,
|
|
1268
|
-
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
1269
|
-
lastInboundMessageId: input.sourceMessageId,
|
|
1270
|
-
});
|
|
1271
|
-
}
|
|
1272
|
-
catch (error) {
|
|
1273
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
1309
|
config.onInputCompleted?.(input.sourceMessageId);
|
|
1277
1310
|
}
|
|
1278
1311
|
/**
|
|
@@ -1935,7 +1968,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1935
1968
|
function hasInterruptibleSdkInput() {
|
|
1936
1969
|
return session.activeInput !== null && session.dispatchingInput === null;
|
|
1937
1970
|
}
|
|
1938
|
-
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
|
|
1971
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}, replyContactTarget) => {
|
|
1939
1972
|
session.lastActivity = Date.now();
|
|
1940
1973
|
const input = createClaudeInputEnvelope({
|
|
1941
1974
|
kind: 'canon',
|
|
@@ -1945,6 +1978,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1945
1978
|
markAccepted,
|
|
1946
1979
|
isOwnerTurn,
|
|
1947
1980
|
requestingUserId,
|
|
1981
|
+
replyContactTarget,
|
|
1948
1982
|
...turnModes,
|
|
1949
1983
|
});
|
|
1950
1984
|
const decision = decideClaudeInboundDispatch({
|
|
@@ -2066,7 +2100,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2066
2100
|
// being written.
|
|
2067
2101
|
if (!isClaudeMainTurnMessage(msg.parent_tool_use_id))
|
|
2068
2102
|
break;
|
|
2069
|
-
if (event?.type === '
|
|
2103
|
+
if (event?.type === 'message_start') {
|
|
2104
|
+
beginClaudeAssistantResponse(session.turnActivity);
|
|
2105
|
+
}
|
|
2106
|
+
else if (event?.type === 'message_stop') {
|
|
2107
|
+
endClaudeAssistantResponse(session.turnActivity);
|
|
2108
|
+
}
|
|
2109
|
+
else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
2110
|
+
// A tool is also a response boundary for speech. This fallback
|
|
2111
|
+
// keeps continual streaming correct on SDK streams that omit raw
|
|
2112
|
+
// message_start/message_stop events.
|
|
2113
|
+
endClaudeAssistantResponse(session.turnActivity);
|
|
2070
2114
|
// Claimed per CALL, keyed by the SDK's tool_use id. The old id was
|
|
2071
2115
|
// the turn id, so every call in a turn wrote over the same row.
|
|
2072
2116
|
const commands = planClaudeToolCallStart(session.turnActivity, {
|
|
@@ -2104,8 +2148,10 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2104
2148
|
stopVisibleWorkSignal();
|
|
2105
2149
|
}
|
|
2106
2150
|
writeTurn();
|
|
2107
|
-
|
|
2108
|
-
|
|
2151
|
+
onStreamDelta(event.delta.text, claimClaudeTextSegmentId(session.turnActivity, {
|
|
2152
|
+
turnId: session.currentTurnId ?? conversationId,
|
|
2153
|
+
index: event.index,
|
|
2154
|
+
}));
|
|
2109
2155
|
}
|
|
2110
2156
|
break;
|
|
2111
2157
|
}
|
|
@@ -2521,6 +2567,15 @@ export async function main() {
|
|
|
2521
2567
|
hostMode: true,
|
|
2522
2568
|
rtdb,
|
|
2523
2569
|
});
|
|
2570
|
+
const recoveryCheckpointTrackers = new Map();
|
|
2571
|
+
function recoveryCheckpointsFor(conversationId) {
|
|
2572
|
+
let tracker = recoveryCheckpointTrackers.get(conversationId);
|
|
2573
|
+
if (!tracker) {
|
|
2574
|
+
tracker = createClaudeRecoveryCheckpointTracker((messageId) => persistConversationRecoveryCursor(conversationId, messageId));
|
|
2575
|
+
recoveryCheckpointTrackers.set(conversationId, tracker);
|
|
2576
|
+
}
|
|
2577
|
+
return tracker;
|
|
2578
|
+
}
|
|
2524
2579
|
let streamConnected = false;
|
|
2525
2580
|
const hostAvailableExecutionModes = [
|
|
2526
2581
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -2832,6 +2887,7 @@ export async function main() {
|
|
|
2832
2887
|
ownerName,
|
|
2833
2888
|
membershipChange: pendingMembershipChanges.get(input.conversationId) ?? null,
|
|
2834
2889
|
groupContextMode: getGroupContextMode(input.conversationId, conversation),
|
|
2890
|
+
renderOptions: input.renderOptions,
|
|
2835
2891
|
});
|
|
2836
2892
|
}
|
|
2837
2893
|
function closeSession(conversationId, options = {}) {
|
|
@@ -2932,10 +2988,12 @@ export async function main() {
|
|
|
2932
2988
|
const session = sessions.get(conversationId);
|
|
2933
2989
|
if (!session || session.pendingInputs.length === 0)
|
|
2934
2990
|
return;
|
|
2935
|
-
const
|
|
2936
|
-
|
|
2937
|
-
if (session.pendingInputs.length === before)
|
|
2991
|
+
const removed = session.pendingInputs.filter((input) => input.sourceMessageId === sourceMessageId);
|
|
2992
|
+
if (removed.length === 0)
|
|
2938
2993
|
return;
|
|
2994
|
+
session.pendingInputs = session.pendingInputs.filter((input) => input.sourceMessageId !== sourceMessageId);
|
|
2995
|
+
for (const input of removed)
|
|
2996
|
+
session.markInputCompleted(input);
|
|
2939
2997
|
session.writeTurn();
|
|
2940
2998
|
}
|
|
2941
2999
|
function evictOldestIdle() {
|
|
@@ -3033,9 +3091,9 @@ export async function main() {
|
|
|
3033
3091
|
runtimeInputManager,
|
|
3034
3092
|
canRequestApproval: () => canRequestCanonApproval(conversationId),
|
|
3035
3093
|
runtimeId,
|
|
3036
|
-
recoveryBaseCwd: workingDir,
|
|
3037
3094
|
ownerId,
|
|
3038
3095
|
onInputCompleted: (sourceMessageId) => {
|
|
3096
|
+
recoveryCheckpointsFor(conversationId).settle(sourceMessageId);
|
|
3039
3097
|
settleInboundMessageId(sourceMessageId, true);
|
|
3040
3098
|
},
|
|
3041
3099
|
publishSessionSnapshots: (ids) => { void publishSessionSnapshots(ids); },
|
|
@@ -3093,13 +3151,15 @@ export async function main() {
|
|
|
3093
3151
|
}
|
|
3094
3152
|
const sender = m.senderName || m.senderId;
|
|
3095
3153
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
3096
|
-
const
|
|
3154
|
+
const renderOptions = ownerBoundRenderOptions(isOwner);
|
|
3155
|
+
const content = renderInboundContent(m, materialized, renderOptions);
|
|
3097
3156
|
const hydrated = await loadHydratedInboundContext({
|
|
3098
3157
|
conversationId: input.conversationId,
|
|
3099
3158
|
message: m,
|
|
3100
3159
|
senderName: sender,
|
|
3101
3160
|
isOwner,
|
|
3102
3161
|
hydratedPage: input.hydratedPage,
|
|
3162
|
+
renderOptions,
|
|
3103
3163
|
});
|
|
3104
3164
|
const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
|
|
3105
3165
|
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
@@ -3109,6 +3169,7 @@ export async function main() {
|
|
|
3109
3169
|
agentId,
|
|
3110
3170
|
conversationId: input.conversationId,
|
|
3111
3171
|
logPrefix: `[canon-host] [${input.conversationId.slice(0, 8)}]`,
|
|
3172
|
+
renderOptions,
|
|
3112
3173
|
});
|
|
3113
3174
|
const replyContext = replyMedia.replyContext;
|
|
3114
3175
|
const participantContext = hydrated.participantContext;
|
|
@@ -3141,7 +3202,7 @@ export async function main() {
|
|
|
3141
3202
|
return 'handled';
|
|
3142
3203
|
}
|
|
3143
3204
|
session.activeSelfContextId = activeSelfContextId;
|
|
3144
|
-
const
|
|
3205
|
+
const basePromptText = buildCanonPrompt({
|
|
3145
3206
|
content,
|
|
3146
3207
|
conversationId: input.conversationId,
|
|
3147
3208
|
participantContext,
|
|
@@ -3152,6 +3213,12 @@ export async function main() {
|
|
|
3152
3213
|
replyContext,
|
|
3153
3214
|
message: m,
|
|
3154
3215
|
});
|
|
3216
|
+
const lifecycleContext = isOwner
|
|
3217
|
+
? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, input.conversationId))
|
|
3218
|
+
: null;
|
|
3219
|
+
const promptText = lifecycleContext
|
|
3220
|
+
? `${basePromptText}\n\n${lifecycleContext}`
|
|
3221
|
+
: basePromptText;
|
|
3155
3222
|
const messageContent = await buildCanonUserContent({
|
|
3156
3223
|
promptText,
|
|
3157
3224
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
@@ -3169,7 +3236,7 @@ export async function main() {
|
|
|
3169
3236
|
senderId: m.senderId,
|
|
3170
3237
|
senderName: m.senderName,
|
|
3171
3238
|
}),
|
|
3172
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
|
|
3239
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes, ownerBoundReplyContactTarget(replyContext));
|
|
3173
3240
|
return 'queued';
|
|
3174
3241
|
}
|
|
3175
3242
|
const acceptedInboundMessageIds = new Set();
|
|
@@ -3227,16 +3294,65 @@ export async function main() {
|
|
|
3227
3294
|
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
3228
3295
|
})?.lastInboundMessageId ?? null;
|
|
3229
3296
|
}
|
|
3297
|
+
function persistConversationRecoveryCursor(conversationId, messageId) {
|
|
3298
|
+
try {
|
|
3299
|
+
saveRuntimeSessionState(runtimeId, {
|
|
3300
|
+
conversationId,
|
|
3301
|
+
baseCwd: workingDir,
|
|
3302
|
+
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
3303
|
+
lastInboundMessageId: messageId,
|
|
3304
|
+
});
|
|
3305
|
+
return true;
|
|
3306
|
+
}
|
|
3307
|
+
catch (error) {
|
|
3308
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to persist inbound recovery cursor:`, error instanceof Error ? error.message : error);
|
|
3309
|
+
return false;
|
|
3310
|
+
}
|
|
3311
|
+
}
|
|
3312
|
+
let startupRecoveryComplete = false;
|
|
3230
3313
|
async function performMissedInboundRecovery() {
|
|
3314
|
+
const knownBeforeRefresh = new Set(knownConversationIds);
|
|
3315
|
+
await refreshKnownConversationIds(true);
|
|
3316
|
+
const conversationsDiscoveredWhileOffline = startupRecoveryComplete
|
|
3317
|
+
? new Set([...knownConversationIds].filter((id) => !knownBeforeRefresh.has(id)))
|
|
3318
|
+
: new Set();
|
|
3319
|
+
try {
|
|
3320
|
+
let cursor = readLocalRuntimeEntry(runtimeId)?.contactLifecycleCursor ?? null;
|
|
3321
|
+
for (let pageNumber = 0; pageNumber < 10; pageNumber += 1) {
|
|
3322
|
+
const page = await client.listContactRequestLifecyclePage({
|
|
3323
|
+
cursor,
|
|
3324
|
+
limit: 100,
|
|
3325
|
+
});
|
|
3326
|
+
await reconcileContactLifecycleEvents({
|
|
3327
|
+
requests: page.requests,
|
|
3328
|
+
requesterId: agentId,
|
|
3329
|
+
record: (request) => recordLocalRuntimeContactLifecycleEvent(runtimeId, request),
|
|
3330
|
+
});
|
|
3331
|
+
cursor = page.nextCursor;
|
|
3332
|
+
saveLocalRuntimeContactLifecycleCursor(runtimeId, cursor);
|
|
3333
|
+
if (!page.hasMore)
|
|
3334
|
+
break;
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
catch (error) {
|
|
3338
|
+
console.error('[canon-host] Contact lifecycle recovery failed:', error instanceof Error ? error.message : error);
|
|
3339
|
+
}
|
|
3231
3340
|
for (const conversationId of knownConversationIds) {
|
|
3341
|
+
const recoveryCheckpoints = recoveryCheckpointsFor(conversationId);
|
|
3342
|
+
const recoveryBatch = recoveryCheckpoints.reserveBatch();
|
|
3232
3343
|
try {
|
|
3233
3344
|
const recovered = await collectMissedInboundMessages({
|
|
3234
3345
|
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
3235
3346
|
cursor: persistedConversationCursor(conversationId),
|
|
3236
3347
|
agentId,
|
|
3348
|
+
requireContiguousCursor: true,
|
|
3349
|
+
noCursorMode: conversationsDiscoveredWhileOffline.has(conversationId)
|
|
3350
|
+
? 'bounded-window'
|
|
3351
|
+
: 'latest-only',
|
|
3237
3352
|
});
|
|
3238
|
-
|
|
3239
|
-
|
|
3353
|
+
recoveryBatch.commit(recovered.messages.map((message) => message.id), recovered.mode === 'incomplete-gap' ? recovered.recoveryCursor : null);
|
|
3354
|
+
if (recovered.mode === 'incomplete-gap') {
|
|
3355
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] recovery_incomplete_gap: cursor is missing; replaying ${recovered.messages.length} bounded inbound message(s) before advancing to ${recovered.recoveryCursor ?? 'no cursor'}`);
|
|
3240
3356
|
}
|
|
3241
3357
|
for (const message of recovered.messages) {
|
|
3242
3358
|
if (!claimInboundMessageId(message.id))
|
|
@@ -3256,8 +3372,10 @@ export async function main() {
|
|
|
3256
3372
|
}) === 'queued';
|
|
3257
3373
|
}
|
|
3258
3374
|
}
|
|
3259
|
-
if (!queued)
|
|
3375
|
+
if (!queued) {
|
|
3376
|
+
recoveryCheckpoints.settle(message.id);
|
|
3260
3377
|
settleInboundMessageId(message.id, true);
|
|
3378
|
+
}
|
|
3261
3379
|
}
|
|
3262
3380
|
catch (error) {
|
|
3263
3381
|
settleInboundMessageId(message.id, false);
|
|
@@ -3269,21 +3387,14 @@ export async function main() {
|
|
|
3269
3387
|
}
|
|
3270
3388
|
}
|
|
3271
3389
|
catch (error) {
|
|
3390
|
+
recoveryBatch.cancel();
|
|
3272
3391
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Startup recovery failed:`, error instanceof Error ? error.message : error);
|
|
3273
3392
|
}
|
|
3274
3393
|
}
|
|
3394
|
+
startupRecoveryComplete = true;
|
|
3275
3395
|
}
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
if (recoveryInFlight)
|
|
3279
|
-
return recoveryInFlight;
|
|
3280
|
-
recoveryInFlight = performMissedInboundRecovery().finally(() => {
|
|
3281
|
-
recoveryInFlight = null;
|
|
3282
|
-
});
|
|
3283
|
-
return recoveryInFlight;
|
|
3284
|
-
}
|
|
3285
|
-
const shouldRecoverAfterStreamConnect = createReconnectRecoveryGate();
|
|
3286
|
-
await recoverMissedInboundMessages();
|
|
3396
|
+
const reconnectRecovery = createReconnectRecoveryCoordinator(performMissedInboundRecovery);
|
|
3397
|
+
await reconnectRecovery.recoverNow();
|
|
3287
3398
|
// ── Connect SSE stream for inbound Canon messages ──
|
|
3288
3399
|
const stream = new CanonStream({
|
|
3289
3400
|
apiKey,
|
|
@@ -3294,11 +3405,13 @@ export async function main() {
|
|
|
3294
3405
|
const m = payload.message;
|
|
3295
3406
|
if (m.senderId === agentId)
|
|
3296
3407
|
return;
|
|
3408
|
+
const convoId = payload.conversationId;
|
|
3297
3409
|
if (!claimInboundMessageId(m.id))
|
|
3298
3410
|
return;
|
|
3299
|
-
|
|
3411
|
+
recoveryCheckpointsFor(convoId).track(m.id);
|
|
3300
3412
|
knownConversationIds.add(convoId);
|
|
3301
3413
|
if (handleRuntimeReplyMessage(convoId, m)) {
|
|
3414
|
+
recoveryCheckpointsFor(convoId).settle(m.id);
|
|
3302
3415
|
settleInboundMessageId(m.id, true);
|
|
3303
3416
|
return;
|
|
3304
3417
|
}
|
|
@@ -3306,6 +3419,7 @@ export async function main() {
|
|
|
3306
3419
|
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
3307
3420
|
if (payload.turnDispatch && payload.turnDispatch.kind !== 'run_turn') {
|
|
3308
3421
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring server-dispatched observe-only message: ${payload.turnDispatch.reason}`);
|
|
3422
|
+
recoveryCheckpointsFor(convoId).settle(m.id);
|
|
3309
3423
|
settleInboundMessageId(m.id, true);
|
|
3310
3424
|
return;
|
|
3311
3425
|
}
|
|
@@ -3322,7 +3436,8 @@ export async function main() {
|
|
|
3322
3436
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to materialize media:`, error instanceof Error ? error.message : error);
|
|
3323
3437
|
}
|
|
3324
3438
|
}
|
|
3325
|
-
const
|
|
3439
|
+
const renderOptions = ownerBoundRenderOptions(isOwner);
|
|
3440
|
+
const content = renderInboundContent(m, materialized, renderOptions);
|
|
3326
3441
|
const hydrated = await loadHydratedInboundContext({
|
|
3327
3442
|
conversationId: convoId,
|
|
3328
3443
|
message: m,
|
|
@@ -3331,6 +3446,7 @@ export async function main() {
|
|
|
3331
3446
|
activeSelfContextId: payload.activeSelfContextId,
|
|
3332
3447
|
selfContexts: payload.selfContexts,
|
|
3333
3448
|
provenance: payload.provenance,
|
|
3449
|
+
renderOptions,
|
|
3334
3450
|
});
|
|
3335
3451
|
const behavior = payload.behavior ?? hydrated.behavior;
|
|
3336
3452
|
const activeSelfContextId = hydrated.activeSelfContextId;
|
|
@@ -3340,6 +3456,7 @@ export async function main() {
|
|
|
3340
3456
|
agentId,
|
|
3341
3457
|
conversationId: convoId,
|
|
3342
3458
|
logPrefix: `[canon-host] [${convoId.slice(0, 8)}]`,
|
|
3459
|
+
renderOptions,
|
|
3343
3460
|
});
|
|
3344
3461
|
const replyContext = replyMedia.replyContext;
|
|
3345
3462
|
const participantContext = hydrated.participantContext;
|
|
@@ -3378,7 +3495,7 @@ export async function main() {
|
|
|
3378
3495
|
return false;
|
|
3379
3496
|
}
|
|
3380
3497
|
session.activeSelfContextId = activeSelfContextId;
|
|
3381
|
-
const
|
|
3498
|
+
const basePromptText = buildCanonPrompt({
|
|
3382
3499
|
content,
|
|
3383
3500
|
conversationId: convoId,
|
|
3384
3501
|
participantContext,
|
|
@@ -3389,6 +3506,12 @@ export async function main() {
|
|
|
3389
3506
|
replyContext,
|
|
3390
3507
|
message: m,
|
|
3391
3508
|
});
|
|
3509
|
+
const lifecycleContext = isOwner
|
|
3510
|
+
? formatPendingContactLifecycleContext(takeLocalRuntimeContactLifecycleEvents(runtimeId, convoId))
|
|
3511
|
+
: null;
|
|
3512
|
+
const promptText = lifecycleContext
|
|
3513
|
+
? `${basePromptText}\n\n${lifecycleContext}`
|
|
3514
|
+
: basePromptText;
|
|
3392
3515
|
const messageContent = await buildCanonUserContent({
|
|
3393
3516
|
promptText,
|
|
3394
3517
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
@@ -3406,11 +3529,13 @@ export async function main() {
|
|
|
3406
3529
|
senderId: m.senderId,
|
|
3407
3530
|
senderName: m.senderName,
|
|
3408
3531
|
}),
|
|
3409
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
|
|
3532
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes, ownerBoundReplyContactTarget(replyContext));
|
|
3410
3533
|
return true;
|
|
3411
3534
|
})().then((queued) => {
|
|
3412
|
-
if (!queued)
|
|
3535
|
+
if (!queued) {
|
|
3536
|
+
recoveryCheckpointsFor(convoId).settle(m.id);
|
|
3413
3537
|
settleInboundMessageId(m.id, true);
|
|
3538
|
+
}
|
|
3414
3539
|
}).catch((error) => {
|
|
3415
3540
|
settleInboundMessageId(m.id, false);
|
|
3416
3541
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to process inbound message:`, error instanceof Error ? error.message : error);
|
|
@@ -3422,12 +3547,24 @@ export async function main() {
|
|
|
3422
3547
|
onConversationUpdated: (payload) => {
|
|
3423
3548
|
handleConversationUpdated(payload);
|
|
3424
3549
|
},
|
|
3550
|
+
onContactRequestUpdated: (payload) => {
|
|
3551
|
+
if (payload.requesterId !== agentId || !payload.sourceConversationId)
|
|
3552
|
+
return;
|
|
3553
|
+
recordLocalRuntimeContactLifecycleEvent(runtimeId, payload);
|
|
3554
|
+
},
|
|
3555
|
+
onReplayExpired: () => {
|
|
3556
|
+
void reconnectRecovery.onReplayExpired().catch((error) => {
|
|
3557
|
+
console.error('[canon-host] Replay-expired recovery failed:', error);
|
|
3558
|
+
});
|
|
3559
|
+
},
|
|
3425
3560
|
onConnected: () => {
|
|
3426
3561
|
streamConnected = true;
|
|
3427
3562
|
void publishRuntimeHeartbeat();
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3563
|
+
const recovery = reconnectRecovery.onConnected();
|
|
3564
|
+
if (recovery)
|
|
3565
|
+
void recovery.catch((error) => {
|
|
3566
|
+
console.error('[canon-host] Reconnect recovery failed:', error);
|
|
3567
|
+
});
|
|
3431
3568
|
console.error('[canon-host] SSE connected');
|
|
3432
3569
|
},
|
|
3433
3570
|
onDisconnected: () => {
|
package/dist/register.js
CHANGED
|
@@ -37,6 +37,7 @@ After approval, start it with CANON_AGENT=<profile> canon-claude --cwd /path/to/
|
|
|
37
37
|
const OPTIONS = {
|
|
38
38
|
moduleUrl: import.meta.url,
|
|
39
39
|
clientType: 'claude-code',
|
|
40
|
+
sessionSetupPolicy: 'runtime_descriptor_required',
|
|
40
41
|
cliName: 'canon-register',
|
|
41
42
|
hostBinName: 'canon-claude',
|
|
42
43
|
developerInfo: 'Claude Code plugin',
|
package/dist/server.js
CHANGED
|
@@ -16,7 +16,8 @@ import { ApprovalHttpServer } from './approval-server.js';
|
|
|
16
16
|
import { renderClaudeInboundContent } from './canon-user-content.js';
|
|
17
17
|
import { runCli } from '@canonmsg/core';
|
|
18
18
|
import { parseReplyArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
|
|
19
|
-
import { canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, stampSendToTurnComplete, } from '@canonmsg/agent-tools';
|
|
19
|
+
import { OWNER_BOUND_CANON_COMMUNICATION_VERBS, canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, stampSendToTurnComplete, } from '@canonmsg/agent-tools';
|
|
20
|
+
const OWNER_BOUND_COMMUNICATION_VERBS = new Set(OWNER_BOUND_CANON_COMMUNICATION_VERBS);
|
|
20
21
|
const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
|
|
21
22
|
|
|
22
23
|
USAGE
|
|
@@ -174,7 +175,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
174
175
|
// Canonical verbs — projections of canon.verbs.v1 (@canonmsg/agent-tools).
|
|
175
176
|
// Channel mode owns no turn, so `no_reply` is an ack-only no-op here: the
|
|
176
177
|
// standalone session has no final delivery for it to suppress.
|
|
177
|
-
...canonVerbToolDefinitions(),
|
|
178
|
+
...canonVerbToolDefinitions().filter((definition) => !OWNER_BOUND_COMMUNICATION_VERBS.has(definition.name)),
|
|
178
179
|
],
|
|
179
180
|
}));
|
|
180
181
|
// ── Tool handlers ──────────────────────────────────────────────────────
|
|
@@ -314,6 +315,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
314
315
|
}
|
|
315
316
|
default: {
|
|
316
317
|
const name = request.params.name;
|
|
318
|
+
if (OWNER_BOUND_COMMUNICATION_VERBS.has(name)) {
|
|
319
|
+
return toolArgumentError(`${name} requires an owner-authored Canon turn and is unavailable in standalone channel mode.`);
|
|
320
|
+
}
|
|
317
321
|
if (isCanonToolVerb(name)) {
|
|
318
322
|
const rawArgs = args && typeof args === 'object' ? { ...args } : {};
|
|
319
323
|
const verbArgs = name === 'send_to' ? stampSendToTurnComplete(rawArgs) : rawArgs;
|
package/dist/session-state.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
2
|
import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
|
|
3
|
+
import type { OwnerBoundCanonContactTarget } from '@canonmsg/agent-tools';
|
|
3
4
|
import type { TurnArtifactRoutingDecision, TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
|
|
4
5
|
export type ClaudeInputKind = 'seed' | 'canon';
|
|
5
6
|
export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
|
|
@@ -23,6 +24,8 @@ export interface ClaudeInputEnvelope {
|
|
|
23
24
|
turnKey: string;
|
|
24
25
|
isOwnerTurn: boolean;
|
|
25
26
|
requestingUserId: string | null;
|
|
27
|
+
/** Trusted contact-card target resolved from the owner message's reply. */
|
|
28
|
+
replyContactTarget?: OwnerBoundCanonContactTarget;
|
|
26
29
|
}
|
|
27
30
|
/**
|
|
28
31
|
* The per-turn modes an inbound message resolves from its participant context,
|
|
@@ -102,6 +105,7 @@ export declare function createClaudeInputEnvelope(input: {
|
|
|
102
105
|
artifactBaseline?: TurnArtifactSnapshot | null;
|
|
103
106
|
isOwnerTurn?: boolean;
|
|
104
107
|
requestingUserId?: string | null;
|
|
108
|
+
replyContactTarget?: OwnerBoundCanonContactTarget;
|
|
105
109
|
}): ClaudeInputEnvelope;
|
|
106
110
|
export declare function resolveClaudeTurnResponseRouting(turn: Pick<ClaudeInputEnvelope, 'kind' | 'isOwnerTurn' | 'requestingUserId'> | null | undefined, ownerId: string | null | undefined, ownerOnly?: boolean): {
|
|
107
111
|
isOwnerTurn: boolean;
|
|
@@ -356,6 +360,10 @@ export interface ClaudeTurnActivityState {
|
|
|
356
360
|
blockIdByToolUseId: Map<string, string>;
|
|
357
361
|
/** This turn's plan block, once TodoWrite has produced one. */
|
|
358
362
|
planBlockId: string | null;
|
|
363
|
+
/** Monotonic identity for assistant responses inside this turn. */
|
|
364
|
+
assistantResponseCount: number;
|
|
365
|
+
/** The response whose text deltas are currently being streamed. */
|
|
366
|
+
activeAssistantResponseKey: string | null;
|
|
359
367
|
}
|
|
360
368
|
/**
|
|
361
369
|
* Whether an SDK message describes the agent's OWN work rather than a
|
|
@@ -367,6 +375,22 @@ export interface ClaudeTurnActivityState {
|
|
|
367
375
|
export declare function isClaudeMainTurnMessage(parentToolUseId: unknown): boolean;
|
|
368
376
|
export declare function createClaudeTurnActivityState(): ClaudeTurnActivityState;
|
|
369
377
|
export declare function resetClaudeTurnActivityState(state: ClaudeTurnActivityState): void;
|
|
378
|
+
/** Open a fresh assistant response, whose content-block indexes start at zero. */
|
|
379
|
+
export declare function beginClaudeAssistantResponse(state: ClaudeTurnActivityState): string;
|
|
380
|
+
/** Close the response so a later text delta cannot reuse its block identity. */
|
|
381
|
+
export declare function endClaudeAssistantResponse(state: ClaudeTurnActivityState): void;
|
|
382
|
+
/**
|
|
383
|
+
* Stable id for one text block inside one assistant response.
|
|
384
|
+
*
|
|
385
|
+
* Anthropic restarts `content_block.index` for every assistant response. The
|
|
386
|
+
* response key is therefore required to distinguish text before a tool from
|
|
387
|
+
* text emitted after its result. Lazily opening a response keeps older SDK
|
|
388
|
+
* streams that omit `message_start` safe as well.
|
|
389
|
+
*/
|
|
390
|
+
export declare function claimClaudeTextSegmentId(state: ClaudeTurnActivityState, input: {
|
|
391
|
+
turnId?: string | null;
|
|
392
|
+
index?: unknown;
|
|
393
|
+
}): string;
|
|
370
394
|
export declare function claudeToolBlockId(toolUseId: string): string;
|
|
371
395
|
export declare function claudePlanBlockId(turnId: string | null | undefined): string;
|
|
372
396
|
/**
|
package/dist/session-state.js
CHANGED
|
@@ -28,6 +28,9 @@ export function createClaudeInputEnvelope(input) {
|
|
|
28
28
|
requestingUserId: input.kind === 'canon' && input.requestingUserId
|
|
29
29
|
? input.requestingUserId
|
|
30
30
|
: null,
|
|
31
|
+
...(input.kind === 'canon' && input.replyContactTarget
|
|
32
|
+
? { replyContactTarget: input.replyContactTarget }
|
|
33
|
+
: {}),
|
|
31
34
|
turnKey: input.kind === 'canon' && sourceMessageId
|
|
32
35
|
? `canon:${sourceMessageId}`
|
|
33
36
|
: `${input.kind}:${fallbackId}`,
|
|
@@ -376,12 +379,42 @@ export function createClaudeTurnActivityState() {
|
|
|
376
379
|
blockIdByIndex: new Map(),
|
|
377
380
|
blockIdByToolUseId: new Map(),
|
|
378
381
|
planBlockId: null,
|
|
382
|
+
assistantResponseCount: 0,
|
|
383
|
+
activeAssistantResponseKey: null,
|
|
379
384
|
};
|
|
380
385
|
}
|
|
381
386
|
export function resetClaudeTurnActivityState(state) {
|
|
382
387
|
state.blockIdByIndex.clear();
|
|
383
388
|
state.blockIdByToolUseId.clear();
|
|
384
389
|
state.planBlockId = null;
|
|
390
|
+
state.assistantResponseCount = 0;
|
|
391
|
+
state.activeAssistantResponseKey = null;
|
|
392
|
+
}
|
|
393
|
+
/** Open a fresh assistant response, whose content-block indexes start at zero. */
|
|
394
|
+
export function beginClaudeAssistantResponse(state) {
|
|
395
|
+
state.assistantResponseCount += 1;
|
|
396
|
+
state.activeAssistantResponseKey = `response-${state.assistantResponseCount}`;
|
|
397
|
+
return state.activeAssistantResponseKey;
|
|
398
|
+
}
|
|
399
|
+
/** Close the response so a later text delta cannot reuse its block identity. */
|
|
400
|
+
export function endClaudeAssistantResponse(state) {
|
|
401
|
+
state.activeAssistantResponseKey = null;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Stable id for one text block inside one assistant response.
|
|
405
|
+
*
|
|
406
|
+
* Anthropic restarts `content_block.index` for every assistant response. The
|
|
407
|
+
* response key is therefore required to distinguish text before a tool from
|
|
408
|
+
* text emitted after its result. Lazily opening a response keeps older SDK
|
|
409
|
+
* streams that omit `message_start` safe as well.
|
|
410
|
+
*/
|
|
411
|
+
export function claimClaudeTextSegmentId(state, input) {
|
|
412
|
+
const responseKey = state.activeAssistantResponseKey
|
|
413
|
+
?? beginClaudeAssistantResponse(state);
|
|
414
|
+
const index = typeof input.index === 'number' && Number.isInteger(input.index)
|
|
415
|
+
? input.index
|
|
416
|
+
: 'latest';
|
|
417
|
+
return buildTrailBlockId('text', input.turnId ?? 'turn', responseKey, index);
|
|
385
418
|
}
|
|
386
419
|
export function claudeToolBlockId(toolUseId) {
|
|
387
420
|
return buildTrailBlockId('tool', toolUseId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Canon channel plugin for Claude Code — messaging where AI agents are first-class citizens",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -31,10 +31,10 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@anthropic-ai/claude-agent-sdk": "0.3.228",
|
|
34
|
-
"@canonmsg/agent-sdk": "^8.
|
|
35
|
-
"@canonmsg/agent-tools": "^0.
|
|
36
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
37
|
-
"@canonmsg/core": "^10.
|
|
34
|
+
"@canonmsg/agent-sdk": "^8.9.0",
|
|
35
|
+
"@canonmsg/agent-tools": "^0.6.0",
|
|
36
|
+
"@canonmsg/coding-agent-host": "^0.6.0",
|
|
37
|
+
"@canonmsg/core": "^10.7.0",
|
|
38
38
|
"@canonmsg/rich-cards": "^0.10.2",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.30.0"
|
|
40
40
|
},
|