@canonmsg/claude-code-plugin 0.29.3 → 0.30.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 +145 -37
- package/dist/register.js +1 -1
- package/dist/server.js +2 -0
- package/dist/session-state.d.ts +88 -1
- package/dist/session-state.js +93 -1
- package/dist/tool-policy.js +6 -0
- package/package.json +5 -5
- package/skills/register/SKILL.md +1 -1
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
|
@@ -28,12 +28,12 @@ import { readFile } from 'node:fs/promises';
|
|
|
28
28
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
29
29
|
import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
30
30
|
import { captureTurnArtifactSnapshot, collectTurnArtifacts, 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,
|
|
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, resolveSilentTurnDelivery, 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, composeClaudeFinalText, describeUndeliveredClaudeFinal, confirmClaudeInterrupt, createClaudeInputEnvelope, deriveClaudeSupplementalModelProbes, formatClaudeCliVersion, formatClaudeControlError, isClaudeCustomModelOption, isSupportedClaudeCliVersion, mergeClaudeDiscoveredModelOptions, parseClaudeCliVersion, resolveClaudeTurnResponseRouting, resolveClaudeModelOptions, resetClaudeCompletedTurnState, shouldDeliverClaudeFinal, 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)
|
|
@@ -1684,7 +1754,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1684
1754
|
}
|
|
1685
1755
|
runtimeState.writeTurnState(conversationId, {
|
|
1686
1756
|
turnId: session.currentTurnId,
|
|
1687
|
-
state: session.turnState,
|
|
1757
|
+
state: publishedClaudeTurnState(session.turnState, session.turnVerbosity),
|
|
1688
1758
|
queueDepth: session.pendingInputs.length,
|
|
1689
1759
|
currentSpeakerId: agentId,
|
|
1690
1760
|
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
@@ -1747,6 +1817,11 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1747
1817
|
if (session.closed || !claudeInputOwnsTurnSlot(input))
|
|
1748
1818
|
return;
|
|
1749
1819
|
clearIdleResetTimer();
|
|
1820
|
+
// Adopted BEFORE the first publish of this turn, and left alone until the
|
|
1821
|
+
// next one opens: the seed, every delta, the final's trail and the
|
|
1822
|
+
// turn-state writes all read it, and a mid-turn change would make them
|
|
1823
|
+
// disagree with each other.
|
|
1824
|
+
session.turnVerbosity = input.turnVerbosity;
|
|
1750
1825
|
openClaudeTurn(session);
|
|
1751
1826
|
startVisibleWorkSignal();
|
|
1752
1827
|
writeTurn();
|
|
@@ -1874,7 +1949,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1874
1949
|
function hasInterruptibleSdkInput() {
|
|
1875
1950
|
return session.activeInput !== null && session.dispatchingInput === null;
|
|
1876
1951
|
}
|
|
1877
|
-
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null,
|
|
1952
|
+
enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false, isOwnerTurn = false, requestingUserId = null, turnModes = {}) => {
|
|
1878
1953
|
session.lastActivity = Date.now();
|
|
1879
1954
|
const input = createClaudeInputEnvelope({
|
|
1880
1955
|
kind: 'canon',
|
|
@@ -1884,7 +1959,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1884
1959
|
markAccepted,
|
|
1885
1960
|
isOwnerTurn,
|
|
1886
1961
|
requestingUserId,
|
|
1887
|
-
|
|
1962
|
+
...turnModes,
|
|
1888
1963
|
});
|
|
1889
1964
|
const decision = decideClaudeInboundDispatch({
|
|
1890
1965
|
intent,
|
|
@@ -2035,7 +2110,13 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2035
2110
|
else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
2036
2111
|
session.toolInProgress = false;
|
|
2037
2112
|
session.turnState = 'streaming';
|
|
2038
|
-
|
|
2113
|
+
// The dots stop here only because the live bubble takes over as
|
|
2114
|
+
// the indicator. A quiet turn has no bubble, so stopping them
|
|
2115
|
+
// would leave the reader with nothing for the whole generation
|
|
2116
|
+
// phase — the longest stretch of the turn.
|
|
2117
|
+
if (shouldStopTypingDotsOnStreamedText(session.turnVerbosity)) {
|
|
2118
|
+
stopVisibleWorkSignal();
|
|
2119
|
+
}
|
|
2039
2120
|
writeTurn();
|
|
2040
2121
|
const blockIndex = typeof event.index === 'number' ? event.index : 'latest';
|
|
2041
2122
|
onStreamDelta(event.delta.text, `text:${session.currentTurnId ?? conversationId}:${blockIndex}`);
|
|
@@ -2161,7 +2242,14 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2161
2242
|
streamedText: session.pendingFinalText,
|
|
2162
2243
|
failureNotice,
|
|
2163
2244
|
});
|
|
2164
|
-
const
|
|
2245
|
+
const silenced = Boolean(completedInput?.turnKey && session.silencedTurnKeys.has(completedInput.turnKey));
|
|
2246
|
+
// `no_reply` suppresses the MODEL's reply, never Canon's own "this
|
|
2247
|
+
// turn broke" diagnostic: a turn that goes silent and then fails
|
|
2248
|
+
// would otherwise leave the owner with nothing at all, which is the
|
|
2249
|
+
// exact outcome the notice exists to prevent.
|
|
2250
|
+
const suppressFinal = silenced && !failureNotice;
|
|
2251
|
+
const delivery = resolveSilentTurnDelivery({ silenced: suppressFinal, finalText });
|
|
2252
|
+
const shouldDeliverFinal = delivery === 'deliver'
|
|
2165
2253
|
? shouldDeliverClaudeFinal({
|
|
2166
2254
|
turn: completedInput,
|
|
2167
2255
|
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
@@ -2179,6 +2267,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2179
2267
|
turn: completedInput,
|
|
2180
2268
|
finalizedTurnKeys: session.finalizedTurnKeys,
|
|
2181
2269
|
interruptedTurnKeys: session.interruptedTurnKeys,
|
|
2270
|
+
silencedTurnKeys: suppressFinal ? session.silencedTurnKeys : undefined,
|
|
2182
2271
|
}));
|
|
2183
2272
|
}
|
|
2184
2273
|
const finalDelivered = finalText && shouldDeliverFinal
|
|
@@ -2209,6 +2298,19 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2209
2298
|
if (shouldDeliverFinal && finalText) {
|
|
2210
2299
|
scheduleFinalHandoffReset();
|
|
2211
2300
|
}
|
|
2301
|
+
else if (suppressFinal) {
|
|
2302
|
+
// Deliberate silence: blank the node (text '' AND an explicit
|
|
2303
|
+
// empty blocks array) before deleting it, or onStreamingCleared
|
|
2304
|
+
// salvages this turn's narration into a durable bubble. Teardown
|
|
2305
|
+
// then rides the delivered-final handoff so the thinking
|
|
2306
|
+
// indicator ends exactly as it does for a real reply.
|
|
2307
|
+
await streamingOutput.blankAndClear().catch(() => { });
|
|
2308
|
+
// The live row is gone the moment the node is; without this the
|
|
2309
|
+
// handoff timer would leave typing dots behind it for 750 ms,
|
|
2310
|
+
// reading as "started to answer, then gave up".
|
|
2311
|
+
stopVisibleWorkSignal();
|
|
2312
|
+
scheduleFinalHandoffReset();
|
|
2313
|
+
}
|
|
2212
2314
|
else {
|
|
2213
2315
|
// Nothing durable is coming, so retire the live bubble here.
|
|
2214
2316
|
// The delivering path does this after a handoff delay; without
|
|
@@ -2322,9 +2424,15 @@ export async function main() {
|
|
|
2322
2424
|
'runtime-visibility': { type: 'string' },
|
|
2323
2425
|
'show-runtime-detail': { type: 'string', multiple: true },
|
|
2324
2426
|
'hide-runtime-detail': { type: 'string', multiple: true },
|
|
2427
|
+
'turn-verbosity': { type: 'string' },
|
|
2325
2428
|
},
|
|
2326
2429
|
strict: false,
|
|
2327
2430
|
});
|
|
2431
|
+
configuredTurnVerbosity = resolveConfiguredClaudeTurnVerbosity({
|
|
2432
|
+
flag: args['turn-verbosity'],
|
|
2433
|
+
env: process.env.CANON_TURN_VERBOSITY,
|
|
2434
|
+
onWarning: (message) => console.error(`[canon-host] ${message}`),
|
|
2435
|
+
});
|
|
2328
2436
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
2329
2437
|
const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
|
|
2330
2438
|
const configuredWorkspaceRoots = (args['workspace-root'] ?? []).filter((value) => typeof value === 'string');
|
|
@@ -3051,7 +3159,7 @@ export async function main() {
|
|
|
3051
3159
|
promptText,
|
|
3052
3160
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
3053
3161
|
});
|
|
3054
|
-
const
|
|
3162
|
+
const turnModes = resolveClaudeTurnModes(participantContext);
|
|
3055
3163
|
session.enqueueInbound({
|
|
3056
3164
|
type: 'user',
|
|
3057
3165
|
message: {
|
|
@@ -3064,7 +3172,7 @@ export async function main() {
|
|
|
3064
3172
|
senderId: m.senderId,
|
|
3065
3173
|
senderName: m.senderName,
|
|
3066
3174
|
}),
|
|
3067
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null,
|
|
3175
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
|
|
3068
3176
|
return 'queued';
|
|
3069
3177
|
}
|
|
3070
3178
|
const acceptedInboundMessageIds = new Set();
|
|
@@ -3288,7 +3396,7 @@ export async function main() {
|
|
|
3288
3396
|
promptText,
|
|
3289
3397
|
materialized: [...replyMedia.materialized, ...materialized],
|
|
3290
3398
|
});
|
|
3291
|
-
const
|
|
3399
|
+
const turnModes = resolveClaudeTurnModes(participantContext);
|
|
3292
3400
|
session.enqueueInbound({
|
|
3293
3401
|
type: 'user',
|
|
3294
3402
|
message: {
|
|
@@ -3301,7 +3409,7 @@ export async function main() {
|
|
|
3301
3409
|
senderId: m.senderId,
|
|
3302
3410
|
senderName: m.senderName,
|
|
3303
3411
|
}),
|
|
3304
|
-
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null,
|
|
3412
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted, isOwner, m.senderType === 'human' ? m.senderId : null, turnModes);
|
|
3305
3413
|
return true;
|
|
3306
3414
|
})().then((queued) => {
|
|
3307
3415
|
if (!queued)
|
package/dist/register.js
CHANGED
|
@@ -21,7 +21,7 @@ REQUIRED
|
|
|
21
21
|
|
|
22
22
|
FLAGS
|
|
23
23
|
--profile <name> Local profile name in ~/.canon/agents.json
|
|
24
|
-
--environment <id> Canon environment ID (or CANON_ENVIRONMENT_ID)
|
|
24
|
+
--environment <id> Canon environment ID (or CANON_ENVIRONMENT_ID; default: canon-prod-v1)
|
|
25
25
|
--base-url <url> Canon API base URL override
|
|
26
26
|
--stream-url <url> Canon stream URL override
|
|
27
27
|
--rtdb-url <url> Canon RTDB URL override
|
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,5 +1,5 @@
|
|
|
1
1
|
import type { PermissionMode, SDKMessageOrigin, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
-
import type { DeliveryIntent, ModelOption, TurnLifecycleState, TurnOutputBlock } from '@canonmsg/core';
|
|
2
|
+
import type { DeliveryIntent, ModelOption, RuntimeStreamingPayload, TurnLifecycleState, TurnOutputBlock, TurnOutputSnapshot, TurnVerbosity } from '@canonmsg/core';
|
|
3
3
|
import type { TurnArtifactSnapshot } from '@canonmsg/coding-agent-host';
|
|
4
4
|
export type ClaudeInputKind = 'seed' | 'canon';
|
|
5
5
|
export type ClaudeArtifactRoutingMode = 'workspace-generated' | 'disabled';
|
|
@@ -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;
|
|
@@ -123,6 +139,7 @@ export declare function describeUndeliveredClaudeFinal(input: {
|
|
|
123
139
|
turn: ClaudeInputEnvelope | null;
|
|
124
140
|
finalizedTurnKeys: ReadonlySet<string>;
|
|
125
141
|
interruptedTurnKeys: ReadonlySet<string>;
|
|
142
|
+
silencedTurnKeys?: ReadonlySet<string>;
|
|
126
143
|
}): string;
|
|
127
144
|
export declare function resetClaudeCompletedTurnState(session: ClaudeCompletedTurnState): void;
|
|
128
145
|
export interface ClaudeTurnSlotState {
|
|
@@ -550,6 +567,76 @@ export declare function claudeFinalWillChunk(finalText: string, maxTextBytes?: n
|
|
|
550
567
|
* which keeps the common case byte-identical to before.
|
|
551
568
|
*/
|
|
552
569
|
export declare function prepareTurnTrailForDelivery(trail: ReadonlyArray<TurnOutputBlock>, willChunk: boolean): TurnOutputBlock[];
|
|
570
|
+
/**
|
|
571
|
+
* The trail to hang on this turn's final.
|
|
572
|
+
*
|
|
573
|
+
* The quiet gate lives HERE rather than in the controller because the two are
|
|
574
|
+
* genuinely separate decisions: a quiet turn runs its controller in `'status'`
|
|
575
|
+
* mode, which drops the live writes but keeps accumulating blocks in memory —
|
|
576
|
+
* so `getBlocks()` still returns a full trail for a turn the reader watched in
|
|
577
|
+
* silence. Forgetting this second gate is the one way quiet mode half-ships.
|
|
578
|
+
*
|
|
579
|
+
* Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
|
|
580
|
+
* stops at the first block that would exceed its byte budget, and Claude's text
|
|
581
|
+
* blocks are the fattest entries — bounding first would spend the budget on
|
|
582
|
+
* blocks a chunked final then discards.
|
|
583
|
+
*/
|
|
584
|
+
export declare function claudeFinalTurnTrail(input: {
|
|
585
|
+
blocks: ReadonlyArray<TurnOutputBlock>;
|
|
586
|
+
turnId: string;
|
|
587
|
+
willChunk: boolean;
|
|
588
|
+
turnVerbosity: TurnVerbosity;
|
|
589
|
+
}): TurnOutputBlock[];
|
|
590
|
+
/**
|
|
591
|
+
* The turn state a quiet turn publishes to `/turn-state`.
|
|
592
|
+
*
|
|
593
|
+
* Current clients render "is thinking" for every open non-waiting state, so
|
|
594
|
+
* the chat header is unchanged. It exists for app binaries older than #602,
|
|
595
|
+
* whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
|
|
596
|
+
* its turn state was `streaming` or `tool` — on the assumption that a live
|
|
597
|
+
* bubble was carrying the state instead. A quiet turn has no bubble, so such a
|
|
598
|
+
* binary would show nothing at all for the back half of the turn. Staying on
|
|
599
|
+
* `thinking` keeps its dots.
|
|
600
|
+
*
|
|
601
|
+
* One current consumer does read the difference: the direct-chat session strip
|
|
602
|
+
* (`buildAgentSessionPresentation`, which early-returns for group chats) labels
|
|
603
|
+
* turn state `streaming` "Streaming" / "Live preview" and `thinking`
|
|
604
|
+
* "Thinking". So a direct chat started with an explicit `--turn-verbosity
|
|
605
|
+
* quiet` reads "Thinking" for the whole turn — which is what a turn publishing
|
|
606
|
+
* no live preview actually is.
|
|
607
|
+
*
|
|
608
|
+
* `waiting_input` is deliberately NOT folded in: it is the one state that
|
|
609
|
+
* changes what the header says and how the clients treat the dots, and a turn
|
|
610
|
+
* blocked on a human is not thinking.
|
|
611
|
+
*/
|
|
612
|
+
export declare function publishedClaudeTurnState(state: TurnLifecycleState, turnVerbosity: TurnVerbosity): TurnLifecycleState;
|
|
613
|
+
/**
|
|
614
|
+
* The `/streaming` write for one snapshot, or `null` for none.
|
|
615
|
+
*
|
|
616
|
+
* Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
|
|
617
|
+
* test can hold rather than one inline comparison a refactor can drop in
|
|
618
|
+
* silence. The gate cannot live in the controller's `mode`: the controller is
|
|
619
|
+
* per-SESSION here while verbosity is per-TURN.
|
|
620
|
+
*
|
|
621
|
+
* A quiet turn publishes no node at all rather than a status-only one. Writing
|
|
622
|
+
* then deleting would hand `onStreamingCleared` something to salvage into a
|
|
623
|
+
* durable bubble the reader was never meant to see; never writing leaves the
|
|
624
|
+
* delete with nothing to fire on.
|
|
625
|
+
*/
|
|
626
|
+
export declare function planClaudeStreamingWrite(input: {
|
|
627
|
+
snapshot: TurnOutputSnapshot;
|
|
628
|
+
turnId: string;
|
|
629
|
+
turnVerbosity: TurnVerbosity;
|
|
630
|
+
}): RuntimeStreamingPayload | null;
|
|
631
|
+
/**
|
|
632
|
+
* When streamed text starts arriving, do the typing dots retire?
|
|
633
|
+
*
|
|
634
|
+
* In verbose they do, and should: the live bubble becomes the indicator, and
|
|
635
|
+
* two indicators for one turn is noise. A quiet turn has no bubble, so the
|
|
636
|
+
* dots are the only thing the reader has for the whole generation phase — the
|
|
637
|
+
* longest stretch of the turn. Spelled the same way on the Codex host.
|
|
638
|
+
*/
|
|
639
|
+
export declare function shouldStopTypingDotsOnStreamedText(turnVerbosity: TurnVerbosity): boolean;
|
|
553
640
|
export declare const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated \u2014 the full answer exceeded Canon's message size limit]_";
|
|
554
641
|
/**
|
|
555
642
|
* Last-resort degradation for a final Canon refuses to accept: keep as much of
|
package/dist/session-state.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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';
|
|
3
|
+
import { buildBoundedTurnTrail, DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, shouldPublishTurnTrail, utf8ByteLength, VERB_LIMITS, } from '@canonmsg/core';
|
|
4
4
|
import { boundTrailBlockMap, buildTrailBlockId, buildUndeliverableFinalNotice as buildHostUndeliverableFinalNotice, normalizePlanStepStatus, normalizeTrailKey, PLAN_BLOCK_TITLE, renderPlanSteps, truncateFailureDetail, } from '@canonmsg/coding-agent-host';
|
|
5
5
|
export function createClaudeInputEnvelope(input) {
|
|
6
6
|
const sourceMessageId = input.sourceMessageId ?? null;
|
|
@@ -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
|
|
@@ -101,6 +105,10 @@ export function claudeInputOwnsTurnSlot(input) {
|
|
|
101
105
|
export function describeUndeliveredClaudeFinal(input) {
|
|
102
106
|
if (!input.turn)
|
|
103
107
|
return 'no Canon turn owns this result';
|
|
108
|
+
// Checked first: callers pass this set only when silence actually gated the
|
|
109
|
+
// turn, and when it did it IS the reason, whatever else is true of the turn.
|
|
110
|
+
if (input.silencedTurnKeys?.has(input.turn.turnKey))
|
|
111
|
+
return 'turn chose no_reply';
|
|
104
112
|
if (input.turn.kind !== 'canon')
|
|
105
113
|
return `owning turn is '${input.turn.kind}'`;
|
|
106
114
|
if (input.interruptedTurnKeys.has(input.turn.turnKey))
|
|
@@ -865,6 +873,90 @@ export function prepareTurnTrailForDelivery(trail, willChunk) {
|
|
|
865
873
|
return [...trail];
|
|
866
874
|
return trail.filter((block) => block.kind !== 'text');
|
|
867
875
|
}
|
|
876
|
+
/**
|
|
877
|
+
* The trail to hang on this turn's final.
|
|
878
|
+
*
|
|
879
|
+
* The quiet gate lives HERE rather than in the controller because the two are
|
|
880
|
+
* genuinely separate decisions: a quiet turn runs its controller in `'status'`
|
|
881
|
+
* mode, which drops the live writes but keeps accumulating blocks in memory —
|
|
882
|
+
* so `getBlocks()` still returns a full trail for a turn the reader watched in
|
|
883
|
+
* silence. Forgetting this second gate is the one way quiet mode half-ships.
|
|
884
|
+
*
|
|
885
|
+
* Filtering runs BEFORE bounding, as it did inline: `buildBoundedTurnTrail`
|
|
886
|
+
* stops at the first block that would exceed its byte budget, and Claude's text
|
|
887
|
+
* blocks are the fattest entries — bounding first would spend the budget on
|
|
888
|
+
* blocks a chunked final then discards.
|
|
889
|
+
*/
|
|
890
|
+
export function claudeFinalTurnTrail(input) {
|
|
891
|
+
if (!shouldPublishTurnTrail(input.turnVerbosity))
|
|
892
|
+
return [];
|
|
893
|
+
const filtered = prepareTurnTrailForDelivery(input.blocks, input.willChunk);
|
|
894
|
+
return buildBoundedTurnTrail(filtered).map((block) => ({ ...block, turnId: input.turnId }));
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* The turn state a quiet turn publishes to `/turn-state`.
|
|
898
|
+
*
|
|
899
|
+
* Current clients render "is thinking" for every open non-waiting state, so
|
|
900
|
+
* the chat header is unchanged. It exists for app binaries older than #602,
|
|
901
|
+
* whose `filterVisibleTypingUsers` suppressed an agent's typing dots whenever
|
|
902
|
+
* its turn state was `streaming` or `tool` — on the assumption that a live
|
|
903
|
+
* bubble was carrying the state instead. A quiet turn has no bubble, so such a
|
|
904
|
+
* binary would show nothing at all for the back half of the turn. Staying on
|
|
905
|
+
* `thinking` keeps its dots.
|
|
906
|
+
*
|
|
907
|
+
* One current consumer does read the difference: the direct-chat session strip
|
|
908
|
+
* (`buildAgentSessionPresentation`, which early-returns for group chats) labels
|
|
909
|
+
* turn state `streaming` "Streaming" / "Live preview" and `thinking`
|
|
910
|
+
* "Thinking". So a direct chat started with an explicit `--turn-verbosity
|
|
911
|
+
* quiet` reads "Thinking" for the whole turn — which is what a turn publishing
|
|
912
|
+
* no live preview actually is.
|
|
913
|
+
*
|
|
914
|
+
* `waiting_input` is deliberately NOT folded in: it is the one state that
|
|
915
|
+
* changes what the header says and how the clients treat the dots, and a turn
|
|
916
|
+
* blocked on a human is not thinking.
|
|
917
|
+
*/
|
|
918
|
+
export function publishedClaudeTurnState(state, turnVerbosity) {
|
|
919
|
+
if (turnVerbosity !== 'quiet')
|
|
920
|
+
return state;
|
|
921
|
+
return state === 'streaming' || state === 'tool' ? 'thinking' : state;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* The `/streaming` write for one snapshot, or `null` for none.
|
|
925
|
+
*
|
|
926
|
+
* Extracted from the session's `writeSnapshot` so the quiet gate is a unit a
|
|
927
|
+
* test can hold rather than one inline comparison a refactor can drop in
|
|
928
|
+
* silence. The gate cannot live in the controller's `mode`: the controller is
|
|
929
|
+
* per-SESSION here while verbosity is per-TURN.
|
|
930
|
+
*
|
|
931
|
+
* A quiet turn publishes no node at all rather than a status-only one. Writing
|
|
932
|
+
* then deleting would hand `onStreamingCleared` something to salvage into a
|
|
933
|
+
* durable bubble the reader was never meant to see; never writing leaves the
|
|
934
|
+
* delete with nothing to fire on.
|
|
935
|
+
*/
|
|
936
|
+
export function planClaudeStreamingWrite(input) {
|
|
937
|
+
if (input.turnVerbosity === 'quiet')
|
|
938
|
+
return null;
|
|
939
|
+
return {
|
|
940
|
+
text: input.snapshot.text,
|
|
941
|
+
status: input.snapshot.status,
|
|
942
|
+
messageId: input.turnId,
|
|
943
|
+
turnId: input.turnId,
|
|
944
|
+
...(input.snapshot.blocks
|
|
945
|
+
? { blocks: input.snapshot.blocks.map((block) => ({ ...block, turnId: input.turnId })) }
|
|
946
|
+
: {}),
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* When streamed text starts arriving, do the typing dots retire?
|
|
951
|
+
*
|
|
952
|
+
* In verbose they do, and should: the live bubble becomes the indicator, and
|
|
953
|
+
* two indicators for one turn is noise. A quiet turn has no bubble, so the
|
|
954
|
+
* dots are the only thing the reader has for the whole generation phase — the
|
|
955
|
+
* longest stretch of the turn. Spelled the same way on the Codex host.
|
|
956
|
+
*/
|
|
957
|
+
export function shouldStopTypingDotsOnStreamedText(turnVerbosity) {
|
|
958
|
+
return turnVerbosity !== 'quiet';
|
|
959
|
+
}
|
|
868
960
|
export const CLAUDE_FINAL_TRUNCATION_MARKER = "\n\n_[truncated — the full answer exceeded Canon's message size limit]_";
|
|
869
961
|
/** How far back from the cut a paragraph break is still worth cutting at. */
|
|
870
962
|
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.30.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.
|
|
34
|
+
"@canonmsg/agent-sdk": "^8.2.0",
|
|
35
|
+
"@canonmsg/agent-tools": "^0.4.0",
|
|
36
36
|
"@canonmsg/coding-agent-host": "^0.4.0",
|
|
37
|
-
"@canonmsg/core": "^
|
|
38
|
-
"@canonmsg/rich-cards": "^0.9.
|
|
37
|
+
"@canonmsg/core": "^10.0.0",
|
|
38
|
+
"@canonmsg/rich-cards": "^0.9.1",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
package/skills/register/SKILL.md
CHANGED
|
@@ -18,7 +18,7 @@ Register a new Canon agent so it can send and receive messages. Each agent is sa
|
|
|
18
18
|
- **Agent name** — The display name for the agent in Canon
|
|
19
19
|
- **Description** — What the agent does (shown to users in Canon)
|
|
20
20
|
- **Owner phone number** — The Canon account owner's phone number in E.164 format (e.g., +15551234567)
|
|
21
|
-
- **Canon environment** — The trust-domain ID supplied by Canon (`canon-prod-v1` for production, `canon-dev-v1` for dev).
|
|
21
|
+
- **Canon environment** — The trust-domain ID supplied by Canon (`canon-prod-v1` for production, `canon-dev-v1` for dev). Left unset, the CLI registers against `canon-prod-v1` and says so; always confirm with the user rather than letting it default.
|
|
22
22
|
- **Profile name** (optional) — A short identifier for this agent (e.g., "reviewer", "notifier"). Defaults to a sanitized version of the agent name.
|
|
23
23
|
|
|
24
24
|
2. Run the registration CLI:
|