@canonmsg/claude-code-plugin 0.29.4 → 0.31.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 +19 -0
- package/dist/host.d.ts +28 -1
- package/dist/host.js +188 -74
- package/dist/server.js +2 -0
- package/dist/session-state.d.ts +141 -2
- package/dist/session-state.js +154 -2
- package/dist/tool-policy.js +6 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -47,6 +47,7 @@ Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts:
|
|
|
47
47
|
- **Interrupt** — Stop Claude mid-response from the app
|
|
48
48
|
- **Context meter** — See context window usage in the app
|
|
49
49
|
- **Max plan auth** — Uses your Claude subscription, no API key billing
|
|
50
|
+
- **Quiet group turns** — In groups the host shows the thinking indicator and the answer only; direct chats keep the live preview and margin activity (`--turn-verbosity`)
|
|
50
51
|
|
|
51
52
|
## Working directory
|
|
52
53
|
|
|
@@ -72,6 +73,24 @@ Current Canon truth for Claude host mode:
|
|
|
72
73
|
- non-owner turns deny local filesystem, shell, web/network, task, MCP, and Canon outbound tools by default in first-party host mode; pure text replies still work, and owners can allow exact safe tool names with `CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS`
|
|
73
74
|
- if worktree creation is unavailable for the selected project, Canon may fall back to shared-project execution and surface the fallback reason in session details
|
|
74
75
|
|
|
76
|
+
## Turn verbosity
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
canon-claude --cwd /path/to/project --turn-verbosity quiet
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`--turn-verbosity <verbose|quiet|auto>` controls how much of a turn's middle readers see. `CANON_TURN_VERBOSITY` is the environment fallback; the flag wins when both are set.
|
|
83
|
+
|
|
84
|
+
| Value | Effect |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `auto` (default, same as unset) | Verbose in direct chats, quiet in groups |
|
|
87
|
+
| `verbose` | Live streaming text plus the margin activity rows on the final, everywhere |
|
|
88
|
+
| `quiet` | The thinking indicator and the answer, nothing in between, everywhere |
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
75
94
|
## Multiple agents
|
|
76
95
|
|
|
77
96
|
```bash
|
package/dist/host.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { type PermissionResult, type SDKControlReloadPluginsResponse } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
-
import { type CanonRuntimeCommandDescriptor } from '@canonmsg/core';
|
|
3
|
+
import { type CanonRuntimeCommandDescriptor, type HostInboundParticipantContext as InboundParticipantContext, type CanonReplyContext, type MessageCreatedPayload, type ResolvedAgentBehaviorPolicy, type TurnVerbosityConfig } from '@canonmsg/core';
|
|
4
|
+
/**
|
|
5
|
+
* `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
|
|
6
|
+
* per-conversation-type default".
|
|
7
|
+
*
|
|
8
|
+
* A value that parses to nothing is reported and then ignored rather than
|
|
9
|
+
* thrown on — this host's `parseArgs` is `strict: false`, so a typo'd flag
|
|
10
|
+
* already survives argv, and refusing to start over a presentation setting
|
|
11
|
+
* would take the agent offline for it. An empty declaration (`ENV=` in a plist
|
|
12
|
+
* or `--turn-verbosity ''`) is absence, not a mistake, so it says nothing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolveConfiguredClaudeTurnVerbosity(input: {
|
|
15
|
+
flag?: unknown;
|
|
16
|
+
env?: string | undefined;
|
|
17
|
+
onWarning?: (message: string) => void;
|
|
18
|
+
}): TurnVerbosityConfig | null;
|
|
4
19
|
export declare function buildClaudePlanAllowResult(input: Record<string, unknown>): PermissionResult;
|
|
5
20
|
/**
|
|
6
21
|
* Claude Code's own slash commands (built-ins, plugins, project .claude/
|
|
@@ -9,4 +24,16 @@ export declare function buildClaudePlanAllowResult(input: Record<string, unknown
|
|
|
9
24
|
* through. Canon-injected aliases keep precedence over native names.
|
|
10
25
|
*/
|
|
11
26
|
export declare function buildNativeSlashCommandDescriptors(native: SDKControlReloadPluginsResponse['commands'], reservedAliases: ReadonlySet<string>): CanonRuntimeCommandDescriptor[];
|
|
27
|
+
export declare const NO_REPLY_TOOL_NAME: string;
|
|
28
|
+
export declare function buildCanonPrompt(input: {
|
|
29
|
+
content: string;
|
|
30
|
+
conversationId: string;
|
|
31
|
+
participantContext: InboundParticipantContext;
|
|
32
|
+
behavior?: ResolvedAgentBehaviorPolicy | null;
|
|
33
|
+
selfContexts?: MessageCreatedPayload['selfContexts'];
|
|
34
|
+
activeSelfContextId?: string | null;
|
|
35
|
+
provenance: NonNullable<MessageCreatedPayload['provenance']>;
|
|
36
|
+
replyContext?: CanonReplyContext | null;
|
|
37
|
+
message?: MessageCreatedPayload['message'];
|
|
38
|
+
}): string;
|
|
12
39
|
export declare function main(): Promise<void>;
|
package/dist/host.js
CHANGED
|
@@ -27,13 +27,13 @@ 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 { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
30
|
-
import { captureTurnArtifactSnapshot,
|
|
31
|
-
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,
|
|
30
|
+
import { captureTurnArtifactSnapshot, createTurnArtifactRouter, IDLE_TIMEOUT_MS, collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from '@canonmsg/coding-agent-host';
|
|
31
|
+
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, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, resolveTurnVerbosity, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
32
32
|
import { runCli } from '@canonmsg/core';
|
|
33
33
|
import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
|
|
34
34
|
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
35
35
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
36
|
-
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, planClaudeToolCallStart, planClaudeToolProgress, planClaudeToolResults,
|
|
36
|
+
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
37
|
import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
|
|
38
38
|
function parseRuntimeVisibilityPreset(value) {
|
|
39
39
|
return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
|
|
@@ -58,6 +58,12 @@ COMMON FLAGS
|
|
|
58
58
|
Override a hidden detail field
|
|
59
59
|
--hide-runtime-detail <field>
|
|
60
60
|
Hide a runtime detail field
|
|
61
|
+
--turn-verbosity <verbose|quiet|auto>
|
|
62
|
+
How much of a turn's middle readers see. Default
|
|
63
|
+
(auto): verbose in direct chats, quiet in groups —
|
|
64
|
+
quiet shows the thinking indicator and the answer,
|
|
65
|
+
with no live narration and no margin activity rows.
|
|
66
|
+
Env: CANON_TURN_VERBOSITY
|
|
61
67
|
--help, -h Show this help
|
|
62
68
|
--version, -V Show package version
|
|
63
69
|
|
|
@@ -80,6 +86,37 @@ let workspaceRootMetadata = [];
|
|
|
80
86
|
let runtimeModels = [];
|
|
81
87
|
const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
82
88
|
const allowedNonOwnerClaudeTools = parseAllowedNonOwnerClaudeTools(process.env.CANON_CLAUDE_NON_OWNER_ALLOWED_TOOLS);
|
|
89
|
+
/**
|
|
90
|
+
* Agent-developer setting, resolved once at startup. Deliberately NOT read from
|
|
91
|
+
* `/session-config`: that path is the USER's per-conversation control plane,
|
|
92
|
+
* and owner ruling 5 puts turn verbosity outside user control.
|
|
93
|
+
*/
|
|
94
|
+
let configuredTurnVerbosity = null;
|
|
95
|
+
/**
|
|
96
|
+
* `--turn-verbosity` beats `CANON_TURN_VERBOSITY`; `null` means "unset, use the
|
|
97
|
+
* per-conversation-type default".
|
|
98
|
+
*
|
|
99
|
+
* A value that parses to nothing is reported and then ignored rather than
|
|
100
|
+
* thrown on — this host's `parseArgs` is `strict: false`, so a typo'd flag
|
|
101
|
+
* already survives argv, and refusing to start over a presentation setting
|
|
102
|
+
* would take the agent offline for it. An empty declaration (`ENV=` in a plist
|
|
103
|
+
* or `--turn-verbosity ''`) is absence, not a mistake, so it says nothing.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveConfiguredClaudeTurnVerbosity(input) {
|
|
106
|
+
const sources = [
|
|
107
|
+
['--turn-verbosity', typeof input.flag === 'string' ? input.flag : undefined],
|
|
108
|
+
['CANON_TURN_VERBOSITY', input.env],
|
|
109
|
+
];
|
|
110
|
+
for (const [name, raw] of sources) {
|
|
111
|
+
const parsed = parseTurnVerbosityConfig(raw);
|
|
112
|
+
if (parsed)
|
|
113
|
+
return parsed;
|
|
114
|
+
if (raw && raw.trim()) {
|
|
115
|
+
input.onWarning?.(`Ignoring ${name}=${JSON.stringify(raw)} — expected verbose, quiet or auto.`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
83
120
|
let runtimeMetadata = {
|
|
84
121
|
account: null,
|
|
85
122
|
mcpServers: [],
|
|
@@ -699,7 +736,19 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
|
|
|
699
736
|
supportsRequiresAction: true,
|
|
700
737
|
supportsNonFinalPermanentMessages: false,
|
|
701
738
|
};
|
|
702
|
-
|
|
739
|
+
/**
|
|
740
|
+
* The silence affordance as the model sees it here: the verbs are mounted as an
|
|
741
|
+
* in-process MCP server, so `no_reply` reaches the model namespaced. The group
|
|
742
|
+
* guidance in the shared frame names it; a runtime without the tool passes
|
|
743
|
+
* nothing and its cue drops the silence clause entirely.
|
|
744
|
+
*
|
|
745
|
+
* The verb segment is type-anchored: renaming the verb in `canon.verbs.v1` must
|
|
746
|
+
* fail the build here rather than leave every group turn pointing the model at
|
|
747
|
+
* a tool it does not have.
|
|
748
|
+
*/
|
|
749
|
+
const NO_REPLY_VERB = 'no_reply';
|
|
750
|
+
export const NO_REPLY_TOOL_NAME = `mcp__${CANON_VERB_MCP_SERVER_NAME}__${NO_REPLY_VERB}`;
|
|
751
|
+
export function buildCanonPrompt(input) {
|
|
703
752
|
return renderCodingHostInboundPrompt(buildCanonInboundFrameV1(buildCanonTurnContextV2({
|
|
704
753
|
content: input.content,
|
|
705
754
|
conversationId: input.conversationId,
|
|
@@ -710,13 +759,27 @@ function buildCanonPrompt(input) {
|
|
|
710
759
|
provenance: input.provenance,
|
|
711
760
|
replyContext: input.replyContext,
|
|
712
761
|
message: input.message,
|
|
713
|
-
})));
|
|
762
|
+
})), { noReplyToolName: NO_REPLY_TOOL_NAME });
|
|
714
763
|
}
|
|
715
764
|
function resolveArtifactRoutingMode(participantContext) {
|
|
716
765
|
return participantContext.conversationType === 'direct' && participantContext.isOwner
|
|
717
766
|
? 'workspace-generated'
|
|
718
767
|
: 'disabled';
|
|
719
768
|
}
|
|
769
|
+
/**
|
|
770
|
+
* Everything about a turn that is decided from the message that started it, in
|
|
771
|
+
* one place. Both are resolved at the enqueue site and ride the envelope so a
|
|
772
|
+
* message that waits in the queue still runs under the answer it arrived with.
|
|
773
|
+
*/
|
|
774
|
+
function resolveClaudeTurnModes(participantContext) {
|
|
775
|
+
return {
|
|
776
|
+
artifactRoutingMode: resolveArtifactRoutingMode(participantContext),
|
|
777
|
+
turnVerbosity: resolveTurnVerbosity({
|
|
778
|
+
configured: configuredTurnVerbosity,
|
|
779
|
+
conversationType: participantContext.conversationType,
|
|
780
|
+
}),
|
|
781
|
+
};
|
|
782
|
+
}
|
|
720
783
|
function renderInboundContent(message, materialized) {
|
|
721
784
|
return renderCanonHostInboundContent(message, materialized);
|
|
722
785
|
}
|
|
@@ -773,7 +836,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
773
836
|
messageQueue.push(input);
|
|
774
837
|
}
|
|
775
838
|
};
|
|
776
|
-
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null,
|
|
839
|
+
let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
|
|
777
840
|
sendInput(createClaudeInputEnvelope({
|
|
778
841
|
kind: 'canon',
|
|
779
842
|
msg,
|
|
@@ -782,7 +845,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
782
845
|
markAccepted,
|
|
783
846
|
isOwnerTurn,
|
|
784
847
|
requestingUserId,
|
|
785
|
-
|
|
848
|
+
...turnModes,
|
|
786
849
|
}));
|
|
787
850
|
};
|
|
788
851
|
// Envelopes pulled by the SDK, keyed by the uuid stamped on each message, so a
|
|
@@ -919,6 +982,12 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
919
982
|
...(session.currentTurnId ? { turnId: session.currentTurnId } : {}),
|
|
920
983
|
...(routing.responseUserId ? { responseUserId: routing.responseUserId } : {}),
|
|
921
984
|
};
|
|
985
|
+
}, (verb) => {
|
|
986
|
+
if (verb !== 'no_reply')
|
|
987
|
+
return;
|
|
988
|
+
const turnKey = session.activeInput?.turnKey;
|
|
989
|
+
if (turnKey)
|
|
990
|
+
session.silencedTurnKeys.add(turnKey);
|
|
922
991
|
}),
|
|
923
992
|
},
|
|
924
993
|
},
|
|
@@ -1028,7 +1097,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1028
1097
|
}
|
|
1029
1098
|
// The SDK leaves plan mode when we allow ExitPlanMode, but it does
|
|
1030
1099
|
// not notify the host, so reset the mirrored permission mode here so
|
|
1031
|
-
// the
|
|
1100
|
+
// the permission chip and session surfaces stop reporting
|
|
1032
1101
|
// 'plan'. Approve-only: on reject/revise the SDK stays in plan mode.
|
|
1033
1102
|
if (session.state.permissionMode === 'plan') {
|
|
1034
1103
|
session.state.permissionMode = 'default';
|
|
@@ -1108,7 +1177,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1108
1177
|
environment,
|
|
1109
1178
|
query: q,
|
|
1110
1179
|
sendInput,
|
|
1111
|
-
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null,
|
|
1180
|
+
enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted, isOwnerTurn, requestingUserId, turnModes),
|
|
1112
1181
|
state: {
|
|
1113
1182
|
model: undefined,
|
|
1114
1183
|
permissionMode: config.permissionMode,
|
|
@@ -1117,6 +1186,9 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1117
1186
|
state: 'idle',
|
|
1118
1187
|
},
|
|
1119
1188
|
availableModels: config.availableModels,
|
|
1189
|
+
// Corrected by the first turn that opens; a session with no turn publishes
|
|
1190
|
+
// nothing anyway.
|
|
1191
|
+
turnVerbosity: 'verbose',
|
|
1120
1192
|
streamingText: '',
|
|
1121
1193
|
streamingTimer: null,
|
|
1122
1194
|
idleResetTimer: null,
|
|
@@ -1126,6 +1198,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1126
1198
|
dispatchingInput: null,
|
|
1127
1199
|
finalizedTurnKeys: new Set(),
|
|
1128
1200
|
interruptedTurnKeys: new Set(),
|
|
1201
|
+
silencedTurnKeys: new Set(),
|
|
1129
1202
|
pendingFinalText: null,
|
|
1130
1203
|
pendingFinalDelivery: null,
|
|
1131
1204
|
runtimeControlErrors: {},
|
|
@@ -1156,16 +1229,15 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1156
1229
|
writeSnapshot: (snapshot) => {
|
|
1157
1230
|
if (session.closed)
|
|
1158
1231
|
return Promise.resolve();
|
|
1159
|
-
const
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
text: snapshot.text,
|
|
1164
|
-
status: snapshot.status,
|
|
1165
|
-
messageId: turnId,
|
|
1166
|
-
turnId,
|
|
1167
|
-
blocks,
|
|
1232
|
+
const write = planClaudeStreamingWrite({
|
|
1233
|
+
snapshot,
|
|
1234
|
+
turnId: session.currentTurnId ?? snapshot.turnId,
|
|
1235
|
+
turnVerbosity: session.turnVerbosity,
|
|
1168
1236
|
});
|
|
1237
|
+
session.streamingText = write?.text ?? '';
|
|
1238
|
+
if (!write)
|
|
1239
|
+
return Promise.resolve();
|
|
1240
|
+
return runtimeState.writeStreaming(conversationId, write);
|
|
1169
1241
|
},
|
|
1170
1242
|
clearSnapshot: () => {
|
|
1171
1243
|
session.streamingText = '';
|
|
@@ -1213,19 +1285,17 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1213
1285
|
config.onInputCompleted?.(input.sourceMessageId);
|
|
1214
1286
|
}
|
|
1215
1287
|
/**
|
|
1216
|
-
* The turn trail to attach to the final send.
|
|
1217
|
-
*
|
|
1218
|
-
*
|
|
1219
|
-
* blocks in sequence order and stops at the first one that would exceed its
|
|
1220
|
-
* byte budget, and Claude's text blocks are the fattest entries — bounding
|
|
1221
|
-
* first would spend the budget on blocks a chunked final then discards,
|
|
1222
|
-
* leaving the margin with fewer tool blocks than it could have shown. With
|
|
1223
|
-
* `willChunk` false this is exactly `getFinalTrail()`.
|
|
1288
|
+
* The turn trail to attach to the final send. Empty on a quiet turn — see
|
|
1289
|
+
* `claudeFinalTurnTrail`, which owns both that gate and the chunked-final
|
|
1290
|
+
* filter.
|
|
1224
1291
|
*/
|
|
1225
1292
|
function getFinalTurnTrail(willChunk) {
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1293
|
+
return claudeFinalTurnTrail({
|
|
1294
|
+
blocks: streamingOutput.getBlocks(),
|
|
1295
|
+
turnId: session.currentTurnId ?? `claude-${conversationId}`,
|
|
1296
|
+
willChunk,
|
|
1297
|
+
turnVerbosity: session.turnVerbosity,
|
|
1298
|
+
});
|
|
1229
1299
|
}
|
|
1230
1300
|
function clearIdleResetTimer() {
|
|
1231
1301
|
if (!session.idleResetTimer)
|
|
@@ -1530,37 +1600,32 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1530
1600
|
metadata: buildArtifactMediaMetadata(),
|
|
1531
1601
|
});
|
|
1532
1602
|
}
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}]
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
async function routeArtifactsForInput(input) {
|
|
1561
|
-
if (input.artifactRoutingMode === 'workspace-generated') {
|
|
1562
|
-
await routeWorkspaceGeneratedArtifacts(input);
|
|
1563
|
-
}
|
|
1603
|
+
/**
|
|
1604
|
+
* The one path from a completed turn to posting its workspace artifacts.
|
|
1605
|
+
*
|
|
1606
|
+
* The router owns the collect-and-post loop, so no completion branch can
|
|
1607
|
+
* reach the workspace around the gate. It is built per turn rather than per
|
|
1608
|
+
* session because its once-only latch is a TURN's latch — Codex reaches
|
|
1609
|
+
* completion through five branches and needs it; this host calls it once,
|
|
1610
|
+
* and gets the same guarantee for free.
|
|
1611
|
+
*
|
|
1612
|
+
* `finalText` is the model's own reply, which decides whether the turn's
|
|
1613
|
+
* silence actually suppresses anything — the same reading
|
|
1614
|
+
* `composeClaudeTurnFinal` makes for the text.
|
|
1615
|
+
*/
|
|
1616
|
+
function routeArtifactsForInput(input, finalText) {
|
|
1617
|
+
return createTurnArtifactRouter({
|
|
1618
|
+
decide: () => shouldRouteClaudeTurnArtifacts({
|
|
1619
|
+
turn: input,
|
|
1620
|
+
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
1621
|
+
silencedTurnKeys: session.silencedTurnKeys,
|
|
1622
|
+
finalText,
|
|
1623
|
+
}),
|
|
1624
|
+
baseline: () => input.artifactBaseline,
|
|
1625
|
+
cwd: () => session.cwd,
|
|
1626
|
+
send: (file) => sendTurnArtifactFile(file),
|
|
1627
|
+
log: (line) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] ${line}`),
|
|
1628
|
+
}).route();
|
|
1564
1629
|
}
|
|
1565
1630
|
function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
|
|
1566
1631
|
if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
|
|
@@ -1684,7 +1749,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1684
1749
|
}
|
|
1685
1750
|
runtimeState.writeTurnState(conversationId, {
|
|
1686
1751
|
turnId: session.currentTurnId,
|
|
1687
|
-
state: session.turnState,
|
|
1752
|
+
state: publishedClaudeTurnState(session.turnState, session.turnVerbosity),
|
|
1688
1753
|
queueDepth: session.pendingInputs.length,
|
|
1689
1754
|
currentSpeakerId: agentId,
|
|
1690
1755
|
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
@@ -1747,6 +1812,11 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1747
1812
|
if (session.closed || !claudeInputOwnsTurnSlot(input))
|
|
1748
1813
|
return;
|
|
1749
1814
|
clearIdleResetTimer();
|
|
1815
|
+
// Adopted BEFORE the first publish of this turn, and left alone until the
|
|
1816
|
+
// next one opens: the seed, every delta, the final's trail and the
|
|
1817
|
+
// turn-state writes all read it, and a mid-turn change would make them
|
|
1818
|
+
// disagree with each other.
|
|
1819
|
+
session.turnVerbosity = input.turnVerbosity;
|
|
1750
1820
|
openClaudeTurn(session);
|
|
1751
1821
|
startVisibleWorkSignal();
|
|
1752
1822
|
writeTurn();
|
|
@@ -1874,7 +1944,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1874
1944
|
function hasInterruptibleSdkInput() {
|
|
1875
1945
|
return session.activeInput !== null && session.dispatchingInput === null;
|
|
1876
1946
|
}
|
|
1877
|
-
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null,
|
|
1947
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
|
|
1878
1948
|
session.lastActivity = Date.now();
|
|
1879
1949
|
const input = createClaudeInputEnvelope({
|
|
1880
1950
|
kind: 'canon',
|
|
@@ -1884,7 +1954,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1884
1954
|
markAccepted,
|
|
1885
1955
|
isOwnerTurn,
|
|
1886
1956
|
requestingUserId,
|
|
1887
|
-
|
|
1957
|
+
...turnModes,
|
|
1888
1958
|
});
|
|
1889
1959
|
const decision = decideClaudeInboundDispatch({
|
|
1890
1960
|
intent,
|
|
@@ -2035,7 +2105,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2035
2105
|
else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
2036
2106
|
session.toolInProgress = false;
|
|
2037
2107
|
session.turnState = 'streaming';
|
|
2038
|
-
|
|
2108
|
+
// The dots stop here only because the live bubble takes over as
|
|
2109
|
+
// the indicator. A quiet turn has no bubble, so stopping them
|
|
2110
|
+
// would leave the reader with nothing for the whole generation
|
|
2111
|
+
// phase — the longest stretch of the turn.
|
|
2112
|
+
if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
|
|
2113
|
+
stopVisibleWorkSignal();
|
|
2114
|
+
}
|
|
2039
2115
|
writeTurn();
|
|
2040
2116
|
const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
|
|
2041
2117
|
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
|
|
@@ -2137,11 +2213,6 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2137
2213
|
// working, at their existing accuracy.
|
|
2138
2214
|
const completedInput = takeClaudeResultOwner(dispatchedInputs, msg.user_message_uuid) ?? session.activeInput;
|
|
2139
2215
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
|
|
2140
|
-
// Turn artifacts land before the final text reply.
|
|
2141
|
-
if (completedInput?.kind === 'canon'
|
|
2142
|
-
&& !session.interruptedTurnKeys.has(completedInput.turnKey)) {
|
|
2143
|
-
await routeArtifactsForInput(completedInput);
|
|
2144
|
-
}
|
|
2145
2216
|
const resultText = typeof msg.result === 'string'
|
|
2146
2217
|
? msg.result.trim()
|
|
2147
2218
|
: '';
|
|
@@ -2156,11 +2227,21 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2156
2227
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn failed (${msg.subtype}): `
|
|
2157
2228
|
+ (errorList.join(' | ') || 'no error detail'));
|
|
2158
2229
|
}
|
|
2159
|
-
const
|
|
2230
|
+
const silenced = Boolean(completedInput?.turnKey && session.silencedTurnKeys.has(completedInput.turnKey));
|
|
2231
|
+
// Composed before the artifacts are routed, because the artifact
|
|
2232
|
+
// gate weighs silence against this same model text — a turn cannot
|
|
2233
|
+
// speak and withhold its files, or the reverse. Composing is pure;
|
|
2234
|
+
// the artifacts still POST first, ahead of the reply they belong to.
|
|
2235
|
+
const turnFinal = composeClaudeTurnFinal({
|
|
2160
2236
|
resultText,
|
|
2161
2237
|
streamedText: session.pendingFinalText,
|
|
2162
2238
|
failureNotice,
|
|
2239
|
+
silenced,
|
|
2163
2240
|
});
|
|
2241
|
+
const finalText = turnFinal.finalText;
|
|
2242
|
+
if (completedInput?.kind === 'canon') {
|
|
2243
|
+
await routeArtifactsForInput(completedInput, turnFinal.modelText);
|
|
2244
|
+
}
|
|
2164
2245
|
const shouldDeliverFinal = finalText
|
|
2165
2246
|
? shouldDeliverClaudeFinal({
|
|
2166
2247
|
turn: completedInput,
|
|
@@ -2168,6 +2249,20 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2168
2249
|
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
2169
2250
|
})
|
|
2170
2251
|
: false;
|
|
2252
|
+
// Silence drops the model's own words without sending them. Say so:
|
|
2253
|
+
// a suppressed final is invisible in the conversation, and the
|
|
2254
|
+
// failure notice that may follow it is Canon's line, not the
|
|
2255
|
+
// model's, so nothing else would record what was swallowed.
|
|
2256
|
+
if (turnFinal.speechSuppressed && turnFinal.modelText) {
|
|
2257
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
2258
|
+
+ `Final not delivered (${turnFinal.modelText.length} chars): `
|
|
2259
|
+
+ describeUndeliveredClaudeFinal({
|
|
2260
|
+
turn: completedInput,
|
|
2261
|
+
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
2262
|
+
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
2263
|
+
silencedTurnKeys: session.silencedTurnKeys,
|
|
2264
|
+
}));
|
|
2265
|
+
}
|
|
2171
2266
|
// Gated finals are dropped without sending. That is correct for the
|
|
2172
2267
|
// seed's own turn, but it is also how a misattributed Canon reply
|
|
2173
2268
|
// disappears — silently, which is why this class of bug went unseen.
|
|
@@ -2209,6 +2304,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2209
2304
|
if (shouldDeliverFinal && finalText) {
|
|
2210
2305
|
scheduleFinalHandoffReset();
|
|
2211
2306
|
}
|
|
2307
|
+
else if (turnFinal.speechSuppressed) {
|
|
2308
|
+
// Deliberate silence: blank the node (text '' AND an explicit
|
|
2309
|
+
// empty blocks array) before deleting it, or onStreamingCleared
|
|
2310
|
+
// salvages this turn's narration into a durable bubble. Teardown
|
|
2311
|
+
// then rides the delivered-final handoff so the thinking
|
|
2312
|
+
// indicator ends exactly as it does for a real reply.
|
|
2313
|
+
await streamingOutput.blankAndClear().catch(() => { });
|
|
2314
|
+
// The live row is gone the moment the node is; without this the
|
|
2315
|
+
// handoff timer would leave typing dots behind it for 750 ms,
|
|
2316
|
+
// reading as "started to answer, then gave up".
|
|
2317
|
+
stopVisibleWorkSignal();
|
|
2318
|
+
scheduleFinalHandoffReset();
|
|
2319
|
+
}
|
|
2212
2320
|
else {
|
|
2213
2321
|
// Nothing durable is coming, so retire the live bubble here.
|
|
2214
2322
|
// The delivering path does this after a handoff delay; without
|
|
@@ -2322,9 +2430,15 @@ export async function main() {
|
|
|
2322
2430
|
'runtime-visibility': { type: 'string' },
|
|
2323
2431
|
'show-runtime-detail': { type: 'string', multiple: true },
|
|
2324
2432
|
'hide-runtime-detail': { type: 'string', multiple: true },
|
|
2433
|
+
'turn-verbosity': { type: 'string' },
|
|
2325
2434
|
},
|
|
2326
2435
|
strict: false,
|
|
2327
2436
|
});
|
|
2437
|
+
configuredTurnVerbosity = resolveConfiguredClaudeTurnVerbosity({
|
|
2438
|
+
flag: args['turn-verbosity'],
|
|
2439
|
+
env: process.env.CANON_TURN_VERBOSITY,
|
|
2440
|
+
onWarning: (message) => console.error(`[canon-host] ${message}`),
|
|
2441
|
+
});
|
|
2328
2442
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
2329
2443
|
const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
|
|
2330
2444
|
const configuredWorkspaceRoots = (args['workspace-root'] ?? []).filter((value) => typeof value === 'string');
|
|
@@ -3051,7 +3165,7 @@ export async function main() {
|
|
|
3051
3165
|
promptText,
|
|
3052
3166
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
3053
3167
|
});
|
|
3054
|
-
const
|
|
3168
|
+
const turnModes = resolveClaudeTurnModes(participantContext);
|
|
3055
3169
|
session.enqueueInbound({
|
|
3056
3170
|
type: 'user',
|
|
3057
3171
|
message: {
|
|
@@ -3064,7 +3178,7 @@ export async function main() {
|
|
|
3064
3178
|
senderId: m.senderId,
|
|
3065
3179
|
senderName: m.senderName,
|
|
3066
3180
|
}),
|
|
3067
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null,
|
|
3181
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
|
|
3068
3182
|
return 'queued';
|
|
3069
3183
|
}
|
|
3070
3184
|
const acceptedInboundMessageIds = new Set();
|
|
@@ -3288,7 +3402,7 @@ export async function main() {
|
|
|
3288
3402
|
promptText,
|
|
3289
3403
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
3290
3404
|
});
|
|
3291
|
-
const
|
|
3405
|
+
const turnModes = resolveClaudeTurnModes(participantContext);
|
|
3292
3406
|
session.enqueueInbound({
|
|
3293
3407
|
type: 'user',
|
|
3294
3408
|
message: {
|
|
@@ -3301,7 +3415,7 @@ export async function main() {
|
|
|
3301
3415
|
senderId: m.senderId,
|
|
3302
3416
|
senderName: m.senderName,
|
|
3303
3417
|
}),
|
|
3304
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null,
|
|
3418
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
|
|
3305
3419
|
return true;
|
|
3306
3420
|
})().then((queued) => {
|
|
3307
3421
|
if (!queued)
|
package/dist/server.js
CHANGED
|
@@ -171,6 +171,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
171
171
|
},
|
|
172
172
|
},
|
|
173
173
|
// Canonical verbs — projections of canon.verbs.v1 (@canonmsg/agent-tools).
|
|
174
|
+
// Channel mode owns no turn, so `no_reply` is an ack-only no-op here: the
|
|
175
|
+
// standalone session has no final delivery for it to suppress.
|
|
174
176
|
...canonVerbToolDefinitions(),
|
|
175
177
|
],
|
|
176
178
|
}));
|
package/dist/session-state.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
-
import type { DeliveryIntent, ModelOption, TurnLifecycleState, TurnOutputBlock } from '@canonmsg/core';
|
|
3
|
-
import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
|
|
2
|
+
import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
|
|
3
|
+
import type { TurnArtifactRoutingDecision, TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
|
|
4
4
|
export type ClaudeInputKind = 'seed' | 'canon';
|
|
5
5
|
export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
|
|
6
6
|
export interface ClaudeInputEnvelope {
|
|
@@ -12,11 +12,26 @@ export interface ClaudeInputEnvelope {
|
|
|
12
12
|
sourceMessageId: string | null;
|
|
13
13
|
markAccepted: boolean;
|
|
14
14
|
artifactRoutingMode: ClaudeArtifactRoutingMode;
|
|
15
|
+
/**
|
|
16
|
+
* How loud this turn is, resolved when the message arrived and carried here
|
|
17
|
+
* so the whole turn — including a resumed final delivery — runs under one
|
|
18
|
+
* answer. The session re-affirms it at `openTurn`, which is what corrects a
|
|
19
|
+
* session created while a conversation fetch was failing.
|
|
20
|
+
*/
|
|
21
|
+
turnVerbosity: TurnVerbosity;
|
|
15
22
|
artifactBaseline: TurnArtifactSnapshot | null;
|
|
16
23
|
turnKey: string;
|
|
17
24
|
isOwnerTurn: boolean;
|
|
18
25
|
requestingUserId: string | null;
|
|
19
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* The per-turn modes an inbound message resolves from its participant context,
|
|
29
|
+
* grouped so they travel together from the enqueue site onto the envelope.
|
|
30
|
+
*/
|
|
31
|
+
export interface ClaudeTurnModes {
|
|
32
|
+
artifactRoutingMode?: ClaudeArtifactRoutingMode;
|
|
33
|
+
turnVerbosity?: TurnVerbosity;
|
|
34
|
+
}
|
|
20
35
|
export interface ClaudeRuntimeControlError {
|
|
21
36
|
value: string;
|
|
22
37
|
message: string;
|
|
@@ -83,6 +98,7 @@ export declare function createClaudeInputEnvelope(input: {
|
|
|
83
98
|
sourceMessageId?: string | null;
|
|
84
99
|
markAccepted?: boolean;
|
|
85
100
|
artifactRoutingMode?: ClaudeArtifactRoutingMode;
|
|
101
|
+
turnVerbosity?: TurnVerbosity;
|
|
86
102
|
artifactBaseline?: TurnArtifactSnapshot | null;
|
|
87
103
|
isOwnerTurn?: boolean;
|
|
88
104
|
requestingUserId?: string | null;
|
|
@@ -102,6 +118,23 @@ export declare function shouldDeliverClaudeFinal(input: {
|
|
|
102
118
|
finalizedTurnKeys: ReadonlySet<string>;
|
|
103
119
|
interruptedTurnKeys: ReadonlySet<string>;
|
|
104
120
|
}): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* The artifact half of `shouldDeliverClaudeFinal`: workspace media is posted as
|
|
123
|
+
* a message, so the same turn-level gates apply to it. Silence is the one this
|
|
124
|
+
* host was missing — see `resolveTurnArtifactRouting` for the ruling.
|
|
125
|
+
*
|
|
126
|
+
* The only place silence is resolved for Claude artifacts. `finalText` is the
|
|
127
|
+
* model's own reply for the turn (never Canon's failure notice, which is a host
|
|
128
|
+
* diagnostic and goes out either way), weighed by `isSilentTurnSuppressed` —
|
|
129
|
+
* the same switch `composeClaudeTurnFinal` reads. One switch, so flipping
|
|
130
|
+
* `DEFAULT_SILENT_TURN_PRECEDENCE` moves a turn's text and its files together.
|
|
131
|
+
*/
|
|
132
|
+
export declare function shouldRouteClaudeTurnArtifacts(input: {
|
|
133
|
+
turn: Pick<ClaudeInputEnvelope, 'artifactRoutingMode' | 'turnKey'>;
|
|
134
|
+
interruptedTurnKeys: ReadonlySet<string>;
|
|
135
|
+
silencedTurnKeys: ReadonlySet<string>;
|
|
136
|
+
finalText: string | null | undefined;
|
|
137
|
+
}): TurnArtifactRoutingDecision;
|
|
105
138
|
export declare function rememberDispatchedClaudeInput(dispatched: Map<string, ClaudeInputEnvelope>, input: ClaudeInputEnvelope): void;
|
|
106
139
|
/**
|
|
107
140
|
* The envelope a turn result belongs to, matched by `user_message_uuid`.
|
|
@@ -123,6 +156,7 @@ export declare function describeUndeliveredClaudeFinal(input: {
|
|
|
123
156
|
turn: ClaudeInputEnvelope | null;
|
|
124
157
|
finalizedTurnKeys: ReadonlySet<string>;
|
|
125
158
|
interruptedTurnKeys: ReadonlySet<string>;
|
|
159
|
+
silencedTurnKeys?: ReadonlySet<string>;
|
|
126
160
|
}): string;
|
|
127
161
|
export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
|
|
128
162
|
export interface ClaudeTurnSlotState {
|
|
@@ -480,6 +514,41 @@ export declare function composeClaudeFinalText(input: {
|
|
|
480
514
|
streamedText?: string | null;
|
|
481
515
|
failureNotice?: string | null;
|
|
482
516
|
}): string | null;
|
|
517
|
+
export interface ClaudeTurnFinal {
|
|
518
|
+
/** What actually goes to Canon, or null when the turn says nothing. */
|
|
519
|
+
finalText: string | null;
|
|
520
|
+
/** The model's own text for the turn, whether or not it is delivered. */
|
|
521
|
+
modelText: string | null;
|
|
522
|
+
/**
|
|
523
|
+
* Silence — not "no text" — is why the model's own words are being dropped.
|
|
524
|
+
* The artifact gate reads the same verdict, so a silenced turn withholds its
|
|
525
|
+
* files and its speech together.
|
|
526
|
+
*/
|
|
527
|
+
speechSuppressed: boolean;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* What a completed turn actually says, once `no_reply` is taken into account.
|
|
531
|
+
*
|
|
532
|
+
* Silence suppresses the MODEL's output and nothing else — its result text or,
|
|
533
|
+
* failing that, the text it streamed before going quiet. Canon's own "this turn
|
|
534
|
+
* broke" notice still goes out: suppressing it would leave the owner with a
|
|
535
|
+
* turn that failed and said nothing, which is exactly what the notice exists to
|
|
536
|
+
* prevent.
|
|
537
|
+
*
|
|
538
|
+
* The two used to be merged. `composeClaudeFinalText` appends the notice to
|
|
539
|
+
* whatever partial text exists, and the host then turned silence OFF entirely
|
|
540
|
+
* whenever a notice was present — so a turn that streamed "Here's the chart:",
|
|
541
|
+
* called `no_reply`, and then hit `error_max_turns` posted the model's line
|
|
542
|
+
* next to the notice while withholding the chart itself. Codex has never done
|
|
543
|
+
* that: its failure branches send `formatCodexTurnFailure(...)` alone and never
|
|
544
|
+
* `result.finalMessage`. This is the same contract on both hosts.
|
|
545
|
+
*/
|
|
546
|
+
export declare function composeClaudeTurnFinal(input: {
|
|
547
|
+
resultText?: string | null;
|
|
548
|
+
streamedText?: string | null;
|
|
549
|
+
failureNotice?: string | null;
|
|
550
|
+
silenced: boolean;
|
|
551
|
+
}): ClaudeTurnFinal;
|
|
483
552
|
export type ClaudeFinalDeliveryFailure = 'retry' | 'permanent';
|
|
484
553
|
/**
|
|
485
554
|
* How the host should react to a failed final-reply send.
|
|
@@ -550,6 +619,76 @@ export declare function claudeFinalWillChunk(finalText: string, maxTextBytes?: n
|
|
|
550
619
|
* which keeps the common case byte-identical to before.
|
|
551
620
|
*/
|
|
552
621
|
export declare function prepareTurnTrailForDelivery(trail: ReadonlyArray<TurnOutputBlock>, willChunk: boolean): TurnOutputBlock[];
|
|
622
|
+
/**
|
|
623
|
+
* The trail to hang on this turn's final.
|
|
624
|
+
*
|
|
625
|
+
* The quiet gate lives HERE rather than in the controller because the two are
|
|
626
|
+
* genuinely separate decisions: a quiet turn runs its controller in `'status'`
|
|
627
|
+
* mode, which drops the live writes but keeps accumulating blocks in memory —
|
|
628
|
+
* so `getBlocks()` still returns a full trail for a turn the reader watched in
|
|
629
|
+
* silence. Forgetting this second gate is the one way quiet mode half-ships.
|
|
630
|
+
*
|
|
631
|
+
* Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
|
|
632
|
+
* stops at the first block that would exceed its byte budget, and Claude's text
|
|
633
|
+
* blocks are the fattest entries — bounding first would spend the budget on
|
|
634
|
+
* blocks a chunked final then discards.
|
|
635
|
+
*/
|
|
636
|
+
export declare function claudeFinalTurnTrail(input: {
|
|
637
|
+
blocks: ReadonlyArray<TurnOutputBlock>;
|
|
638
|
+
turnId: string;
|
|
639
|
+
willChunk: boolean;
|
|
640
|
+
turnVerbosity: TurnVerbosity;
|
|
641
|
+
}): TurnOutputBlock[];
|
|
642
|
+
/**
|
|
643
|
+
* The turn state a quiet turn publishes to `/turn-state`.
|
|
644
|
+
*
|
|
645
|
+
* Current clients render "is thinking" for every open non-waiting state, so
|
|
646
|
+
* the chat header is unchanged. It exists for app binaries older than #602,
|
|
647
|
+
* whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
|
|
648
|
+
* its turn state was `streaming` or `tool` — on the assumption that a live
|
|
649
|
+
* bubble was carrying the state instead. A quiet turn has no bubble, so such a
|
|
650
|
+
* binary would show nothing at all for the back half of the turn. Staying on
|
|
651
|
+
* `thinking` keeps its dots.
|
|
652
|
+
*
|
|
653
|
+
* One current consumer does read the difference: the direct-chat session strip
|
|
654
|
+
* (`buildAgentSessionPresentation`, which early-returns for group chats) labels
|
|
655
|
+
* turn state `streaming` "Streaming" / "Live preview" and `thinking`
|
|
656
|
+
* "Thinking". So a direct chat started with an explicit `--turn-verbosity
|
|
657
|
+
* quiet` reads "Thinking" for the whole turn — which is what a turn publishing
|
|
658
|
+
* no live preview actually is.
|
|
659
|
+
*
|
|
660
|
+
* `waiting_input` is deliberately NOT folded in: it is the one state that
|
|
661
|
+
* changes what the header says and how the clients treat the dots, and a turn
|
|
662
|
+
* blocked on a human is not thinking.
|
|
663
|
+
*/
|
|
664
|
+
export declare function publishedClaudeTurnState(state: TurnLifecycleState, turnVerbosity: TurnVerbosity): TurnLifecycleState;
|
|
665
|
+
/**
|
|
666
|
+
* The `/streaming` write for one snapshot, or `null` for none.
|
|
667
|
+
*
|
|
668
|
+
* Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
|
|
669
|
+
* test can hold rather than one inline comparison a refactor can drop in
|
|
670
|
+
* silence. The gate cannot live in the controller's `mode`: the controller is
|
|
671
|
+
* per-SESSION here while verbosity is per-TURN.
|
|
672
|
+
*
|
|
673
|
+
* A quiet turn publishes no node at all rather than a status-only one. Writing
|
|
674
|
+
* then deleting would hand `onStreamingCleared` something to salvage into a
|
|
675
|
+
* durable bubble the reader was never meant to see; never writing leaves the
|
|
676
|
+
* delete with nothing to fire on.
|
|
677
|
+
*/
|
|
678
|
+
export declare function planClaudeStreamingWrite(input: {
|
|
679
|
+
snapshot: TurnOutputSnapshot;
|
|
680
|
+
turnId: string;
|
|
681
|
+
turnVerbosity: TurnVerbosity;
|
|
682
|
+
}): RuntimeStreamingPayload | null;
|
|
683
|
+
/**
|
|
684
|
+
* When streamed text starts arriving, do the typing dots retire?
|
|
685
|
+
*
|
|
686
|
+
* In verbose they do, and should: the live bubble becomes the indicator, and
|
|
687
|
+
* two indicators for one turn is noise. A quiet turn has no bubble, so the
|
|
688
|
+
* dots are the only thing the reader has for the whole generation phase — the
|
|
689
|
+
* longest stretch of the turn. Spelled the same way on the Codex host.
|
|
690
|
+
*/
|
|
691
|
+
export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
|
|
553
692
|
export declare const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated \u2014 the full answer exceeded Canon's message size limit]_";
|
|
554
693
|
/**
|
|
555
694
|
* Last-resort degradation for a final Canon refuses to accept: keep as much of
|
package/dist/session-state.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { USAGE_LIMIT_ERROR_PREFIXES } from '@anthropic-ai/claude-agent-sdk';
|
|
3
|
-
import { DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
|
|
4
|
-
import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
|
|
3
|
+
import { buildBoundedTurnTrail, DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, isSilentTurnSuppressed, shouldPublishTurnTrail, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
|
|
4
|
+
import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, resolveTurnArtifactRouting, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
|
|
5
5
|
export function createClaudeInputEnvelope(input) {
|
|
6
6
|
const sourceMessageId = input.sourceMessageId ?? null;
|
|
7
7
|
const fallbackId = randomUUID();
|
|
@@ -17,6 +17,10 @@ export function createClaudeInputEnvelope(input) {
|
|
|
17
17
|
sourceMessageId,
|
|
18
18
|
markAccepted: Boolean(input.markAccepted),
|
|
19
19
|
artifactRoutingMode: input.artifactRoutingMode ?? 'disabled',
|
|
20
|
+
// Verbose is the safe default for an envelope nobody resolved (the internal
|
|
21
|
+
// `seed` kind, and any caller that predates the flag): quiet is a decision,
|
|
22
|
+
// never an accident.
|
|
23
|
+
turnVerbosity: input.turnVerbosity ?? 'verbose',
|
|
20
24
|
artifactBaseline: input.artifactBaseline ?? null,
|
|
21
25
|
isOwnerTurn: input.kind === 'canon'
|
|
22
26
|
? input.isOwnerTurn === true
|
|
@@ -56,6 +60,27 @@ export function shouldDeliverClaudeFinal(input) {
|
|
|
56
60
|
return false;
|
|
57
61
|
return true;
|
|
58
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* The artifact half of `shouldDeliverClaudeFinal`: workspace media is posted as
|
|
65
|
+
* a message, so the same turn-level gates apply to it. Silence is the one this
|
|
66
|
+
* host was missing — see `resolveTurnArtifactRouting` for the ruling.
|
|
67
|
+
*
|
|
68
|
+
* The only place silence is resolved for Claude artifacts. `finalText` is the
|
|
69
|
+
* model's own reply for the turn (never Canon's failure notice, which is a host
|
|
70
|
+
* diagnostic and goes out either way), weighed by `isSilentTurnSuppressed` —
|
|
71
|
+
* the same switch `composeClaudeTurnFinal` reads. One switch, so flipping
|
|
72
|
+
* `DEFAULT_SILENT_TURN_PRECEDENCE` moves a turn's text and its files together.
|
|
73
|
+
*/
|
|
74
|
+
export function shouldRouteClaudeTurnArtifacts(input) {
|
|
75
|
+
return resolveTurnArtifactRouting({
|
|
76
|
+
artifactRoutingMode: input.turn.artifactRoutingMode,
|
|
77
|
+
interrupted: input.interruptedTurnKeys.has(input.turn.turnKey),
|
|
78
|
+
silenced: isSilentTurnSuppressed({
|
|
79
|
+
silenced: input.silencedTurnKeys.has(input.turn.turnKey),
|
|
80
|
+
finalText: input.finalText,
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
59
84
|
/** Bounds the correlation map if the SDK ever stops reporting a uuid we sent. */
|
|
60
85
|
const MAX_TRACKED_DISPATCHED_INPUTS = 64;
|
|
61
86
|
export function rememberDispatchedClaudeInput(dispatched, input) {
|
|
@@ -101,6 +126,10 @@ export function claudeInputOwnsTurnSlot(input) {
|
|
|
101
126
|
export function describeUndeliveredClaudeFinal(input) {
|
|
102
127
|
if (!input.turn)
|
|
103
128
|
return 'no Canon turn owns this result';
|
|
129
|
+
// Checked first: callers pass this set only when silence actually gated the
|
|
130
|
+
// turn, and when it did it IS the reason, whatever else is true of the turn.
|
|
131
|
+
if (input.silencedTurnKeys?.has(input.turn.turnKey))
|
|
132
|
+
return 'turn chose no_reply';
|
|
104
133
|
if (input.turn.kind !== 'canon')
|
|
105
134
|
return `owning turn is '${input.turn.kind}'`;
|
|
106
135
|
if (input.interruptedTurnKeys.has(input.turn.turnKey))
|
|
@@ -769,6 +798,45 @@ export function composeClaudeFinalText(input) {
|
|
|
769
798
|
return primary;
|
|
770
799
|
return primary ? `${primary}\n\n${input.failureNotice}` : input.failureNotice;
|
|
771
800
|
}
|
|
801
|
+
/**
|
|
802
|
+
* What a completed turn actually says, once `no_reply` is taken into account.
|
|
803
|
+
*
|
|
804
|
+
* Silence suppresses the MODEL's output and nothing else — its result text or,
|
|
805
|
+
* failing that, the text it streamed before going quiet. Canon's own "this turn
|
|
806
|
+
* broke" notice still goes out: suppressing it would leave the owner with a
|
|
807
|
+
* turn that failed and said nothing, which is exactly what the notice exists to
|
|
808
|
+
* prevent.
|
|
809
|
+
*
|
|
810
|
+
* The two used to be merged. `composeClaudeFinalText` appends the notice to
|
|
811
|
+
* whatever partial text exists, and the host then turned silence OFF entirely
|
|
812
|
+
* whenever a notice was present — so a turn that streamed "Here's the chart:",
|
|
813
|
+
* called `no_reply`, and then hit `error_max_turns` posted the model's line
|
|
814
|
+
* next to the notice while withholding the chart itself. Codex has never done
|
|
815
|
+
* that: its failure branches send `formatCodexTurnFailure(...)` alone and never
|
|
816
|
+
* `result.finalMessage`. This is the same contract on both hosts.
|
|
817
|
+
*/
|
|
818
|
+
export function composeClaudeTurnFinal(input) {
|
|
819
|
+
const modelText = composeClaudeFinalText({
|
|
820
|
+
resultText: input.resultText,
|
|
821
|
+
streamedText: input.streamedText,
|
|
822
|
+
});
|
|
823
|
+
const speechSuppressed = isSilentTurnSuppressed({
|
|
824
|
+
silenced: input.silenced,
|
|
825
|
+
finalText: modelText,
|
|
826
|
+
});
|
|
827
|
+
if (speechSuppressed) {
|
|
828
|
+
return {
|
|
829
|
+
finalText: input.failureNotice?.trim() || null,
|
|
830
|
+
modelText,
|
|
831
|
+
speechSuppressed: true,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
finalText: composeClaudeFinalText(input),
|
|
836
|
+
modelText,
|
|
837
|
+
speechSuppressed: false,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
772
840
|
/**
|
|
773
841
|
* How the host should react to a failed final-reply send.
|
|
774
842
|
*
|
|
@@ -865,6 +933,90 @@ export function prepareTurnTrailForDelivery(trail, willChunk) {
|
|
|
865
933
|
return [...trail];
|
|
866
934
|
return trail.filter((block) => block.kind !== 'text');
|
|
867
935
|
}
|
|
936
|
+
/**
|
|
937
|
+
* The trail to hang on this turn's final.
|
|
938
|
+
*
|
|
939
|
+
* The quiet gate lives HERE rather than in the controller because the two are
|
|
940
|
+
* genuinely separate decisions: a quiet turn runs its controller in `'status'`
|
|
941
|
+
* mode, which drops the live writes but keeps accumulating blocks in memory —
|
|
942
|
+
* so `getBlocks()` still returns a full trail for a turn the reader watched in
|
|
943
|
+
* silence. Forgetting this second gate is the one way quiet mode half-ships.
|
|
944
|
+
*
|
|
945
|
+
* Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
|
|
946
|
+
* stops at the first block that would exceed its byte budget, and Claude's text
|
|
947
|
+
* blocks are the fattest entries — bounding first would spend the budget on
|
|
948
|
+
* blocks a chunked final then discards.
|
|
949
|
+
*/
|
|
950
|
+
export function claudeFinalTurnTrail(input) {
|
|
951
|
+
if (!shouldPublishTurnTrail(input.turnVerbosity))
|
|
952
|
+
return [];
|
|
953
|
+
const filtered = prepareTurnTrailForDelivery(input.blocks, input.willChunk);
|
|
954
|
+
return buildBoundedTurnTrail(filtered).map((block) => ({ ...block, turnId: input.turnId }));
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* The turn state a quiet turn publishes to `/turn-state`.
|
|
958
|
+
*
|
|
959
|
+
* Current clients render "is thinking" for every open non-waiting state, so
|
|
960
|
+
* the chat header is unchanged. It exists for app binaries older than #602,
|
|
961
|
+
* whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
|
|
962
|
+
* its turn state was `streaming` or `tool` — on the assumption that a live
|
|
963
|
+
* bubble was carrying the state instead. A quiet turn has no bubble, so such a
|
|
964
|
+
* binary would show nothing at all for the back half of the turn. Staying on
|
|
965
|
+
* `thinking` keeps its dots.
|
|
966
|
+
*
|
|
967
|
+
* One current consumer does read the difference: the direct-chat session strip
|
|
968
|
+
* (`buildAgentSessionPresentation`, which early-returns for group chats) labels
|
|
969
|
+
* turn state `streaming` "Streaming" / "Live preview" and `thinking`
|
|
970
|
+
* "Thinking". So a direct chat started with an explicit `--turn-verbosity
|
|
971
|
+
* quiet` reads "Thinking" for the whole turn — which is what a turn publishing
|
|
972
|
+
* no live preview actually is.
|
|
973
|
+
*
|
|
974
|
+
* `waiting_input` is deliberately NOT folded in: it is the one state that
|
|
975
|
+
* changes what the header says and how the clients treat the dots, and a turn
|
|
976
|
+
* blocked on a human is not thinking.
|
|
977
|
+
*/
|
|
978
|
+
export function publishedClaudeTurnState(state, turnVerbosity) {
|
|
979
|
+
if (turnVerbosity !== 'quiet')
|
|
980
|
+
return state;
|
|
981
|
+
return state === 'streaming' || state === 'tool' ? 'thinking' : state;
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* The `/streaming` write for one snapshot, or `null` for none.
|
|
985
|
+
*
|
|
986
|
+
* Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
|
|
987
|
+
* test can hold rather than one inline comparison a refactor can drop in
|
|
988
|
+
* silence. The gate cannot live in the controller's `mode`: the controller is
|
|
989
|
+
* per-SESSION here while verbosity is per-TURN.
|
|
990
|
+
*
|
|
991
|
+
* A quiet turn publishes no node at all rather than a status-only one. Writing
|
|
992
|
+
* then deleting would hand `onStreamingCleared` something to salvage into a
|
|
993
|
+
* durable bubble the reader was never meant to see; never writing leaves the
|
|
994
|
+
* delete with nothing to fire on.
|
|
995
|
+
*/
|
|
996
|
+
export function planClaudeStreamingWrite(input) {
|
|
997
|
+
if (input.turnVerbosity === 'quiet')
|
|
998
|
+
return null;
|
|
999
|
+
return {
|
|
1000
|
+
text: input.snapshot.text,
|
|
1001
|
+
status: input.snapshot.status,
|
|
1002
|
+
messageId: input.turnId,
|
|
1003
|
+
turnId: input.turnId,
|
|
1004
|
+
...(input.snapshot.blocks
|
|
1005
|
+
? { blocks: input.snapshot.blocks.map((block) => ({ ...block, turnId: input.turnId })) }
|
|
1006
|
+
: {}),
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* When streamed text starts arriving, do the typing dots retire?
|
|
1011
|
+
*
|
|
1012
|
+
* In verbose they do, and should: the live bubble becomes the indicator, and
|
|
1013
|
+
* two indicators for one turn is noise. A quiet turn has no bubble, so the
|
|
1014
|
+
* dots are the only thing the reader has for the whole generation phase — the
|
|
1015
|
+
* longest stretch of the turn. Spelled the same way on the Codex host.
|
|
1016
|
+
*/
|
|
1017
|
+
export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
|
|
1018
|
+
return turnVerbosity !== 'quiet';
|
|
1019
|
+
}
|
|
868
1020
|
export const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated — the full answer exceeded Canon's message size limit]_";
|
|
869
1021
|
/** How far back from the cut a paragraph break is still worth cutting at. */
|
|
870
1022
|
const CLAUDE_TRUNCATION_BOUNDARY_WINDOW_CHARS = 400;
|
package/dist/tool-policy.js
CHANGED
|
@@ -63,6 +63,9 @@ const CANON_HITL_OR_READ_VERBS = new Set([
|
|
|
63
63
|
'list_contacts',
|
|
64
64
|
'list_contact_requests',
|
|
65
65
|
'list_conversations',
|
|
66
|
+
// Staying quiet posts nothing; an approval card asking permission for
|
|
67
|
+
// silence would gate a no-op behind a human decision.
|
|
68
|
+
'no_reply',
|
|
66
69
|
]);
|
|
67
70
|
// Asking permission to create or poll an interaction would put the interaction
|
|
68
71
|
// behind another interaction and can recurse indefinitely. Fail closed instead.
|
|
@@ -86,6 +89,9 @@ const CANON_NON_OWNER_ALLOWED_VERBS = new Set([
|
|
|
86
89
|
'check_approval',
|
|
87
90
|
'send_card',
|
|
88
91
|
'request_card',
|
|
92
|
+
// The group turn is exactly where silence matters, and group turns are
|
|
93
|
+
// usually non-owner turns.
|
|
94
|
+
'no_reply',
|
|
89
95
|
]);
|
|
90
96
|
export function canonVerbFromToolName(toolName) {
|
|
91
97
|
const normalized = normalizeToolName(toolName);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.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,11 +31,11 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
|
34
|
-
"@canonmsg/agent-sdk": "^8.
|
|
35
|
-
"@canonmsg/agent-tools": "^0.
|
|
36
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
37
|
-
"@canonmsg/core": "^
|
|
38
|
-
"@canonmsg/rich-cards": "^0.9.
|
|
34
|
+
"@canonmsg/agent-sdk": "^8.2.0",
|
|
35
|
+
"@canonmsg/agent-tools": "^0.4.0",
|
|
36
|
+
"@canonmsg/coding-agent-host": "^0.5.0",
|
|
37
|
+
"@canonmsg/core": "^10.1.0",
|
|
38
|
+
"@canonmsg/rich-cards": "^0.9.1",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|