@canonmsg/claude-code-plugin 0.29.0 → 0.29.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +44 -8
- package/dist/approval-server.d.ts +0 -1
- package/dist/approval-server.js +4 -18
- package/dist/host.js +19 -42
- package/dist/register.d.ts +9 -0
- package/dist/register.js +13 -113
- package/dist/server.js +6 -5
- package/dist/session-state.d.ts +9 -22
- package/dist/session-state.js +7 -23
- package/dist/setup.js +4 -6
- package/package.json +6 -6
- package/skills/configure/SKILL.md +31 -22
- package/skills/register/SKILL.md +8 -3
- package/dist/cli-entry.d.ts +0 -2
- package/dist/cli-entry.js +0 -1
- package/dist/inbound-policy.d.ts +0 -22
- package/dist/inbound-policy.js +0 -46
- package/dist/startup-recovery.d.ts +0 -54
- package/dist/startup-recovery.js +0 -102
- package/dist/streaming-buffer.d.ts +0 -16
- package/dist/streaming-buffer.js +0 -52
package/README.md
CHANGED
|
@@ -4,13 +4,14 @@ Connect Claude Code to [Canon](https://github.com/HeyBobChan/canon) — a messag
|
|
|
4
4
|
|
|
5
5
|
## Quick start
|
|
6
6
|
|
|
7
|
-
The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.220 or newer — the model picker lists whatever the CLI reports, so an older binary hides newer model families.
|
|
7
|
+
The package includes a compatible Claude Code runtime. If `claude` is installed on `PATH`, or selected with `CANON_CLAUDE_CLI_PATH`, use Claude Code 2.1.220 or newer — the model picker lists whatever the CLI reports, so an older binary hides newer model families. Either source below that minimum is logged and skipped in favour of the bundled runtime, so a stale install costs model options rather than taking the host down.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
# Install
|
|
11
11
|
npm install -g @canonmsg/claude-code-plugin
|
|
12
12
|
|
|
13
13
|
# Register (approve in Canon app when prompted)
|
|
14
|
+
export CANON_ENVIRONMENT_ID=canon-prod-v1
|
|
14
15
|
canon-register --name "My Claude" --description "My Claude Code agent" --phone "+15551234567"
|
|
15
16
|
|
|
16
17
|
# Run
|
|
@@ -38,6 +39,10 @@ Public docs: <https://canonmail.com/agents/integrations>. Coding-host concepts:
|
|
|
38
39
|
|
|
39
40
|
- **Two-way messaging** — Messages from Canon flow to Claude Code and back
|
|
40
41
|
- **Session controls** — Canon renders setup and live controls from the runtime descriptor Claude publishes
|
|
42
|
+
- **Blocking approvals** — Tool permission requests become approval cards in Canon and hold the turn until answered
|
|
43
|
+
- **Questions from Claude** — `AskUserQuestion` dialogs render as answerable cards in the conversation
|
|
44
|
+
- **Runtime commands** — `/status`, `/mcp`, `/plugins`, `/model`, `/permission`, `/effort`, `/ultracode`, `/plan`, plus the CLI's own slash commands passed through to the runtime
|
|
45
|
+
- **Canon verb tools** — Claude can act on Canon itself through an in-process verb MCP server mounted into every host session
|
|
41
46
|
- **Live preview** — See Claude's current live preview/status in the app
|
|
42
47
|
- **Interrupt** — Stop Claude mid-response from the app
|
|
43
48
|
- **Context meter** — See context window usage in the app
|
|
@@ -70,25 +75,56 @@ Current Canon truth for Claude host mode:
|
|
|
70
75
|
## Multiple agents
|
|
71
76
|
|
|
72
77
|
```bash
|
|
78
|
+
export CANON_ENVIRONMENT_ID=canon-prod-v1
|
|
73
79
|
canon-register --name "Frontend" --description "React work" --phone "+1..." --profile frontend
|
|
74
80
|
CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
|
|
75
81
|
```
|
|
76
82
|
|
|
83
|
+
With more than one registered profile, `CANON_AGENT` is required — the host refuses to guess and lists the available profiles instead.
|
|
84
|
+
|
|
77
85
|
## Channel mode (alternative)
|
|
78
86
|
|
|
79
|
-
For a lighter integration without session controls, run Canon as a channel
|
|
87
|
+
For a lighter integration without session controls, run Canon as a channel inside your own Claude Code session. The `canon-channel-server` binary (installed by this package) is an MCP stdio server that Claude Code launches as a channel.
|
|
88
|
+
|
|
89
|
+
Add it to the project's `.mcp.json` (or `~/.mcp.json` for global):
|
|
80
90
|
|
|
81
|
-
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"mcpServers": {
|
|
94
|
+
"canon-channel": {
|
|
95
|
+
"command": "canon-channel-server"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then start Claude Code with the channel loaded:
|
|
82
102
|
|
|
83
103
|
```bash
|
|
84
|
-
|
|
85
|
-
claude
|
|
104
|
+
CANON_API_KEY=agk_live_... CANON_ENVIRONMENT_ID=canon-prod-v1 \
|
|
105
|
+
claude --dangerously-load-development-channels server:canon-channel
|
|
106
|
+
```
|
|
86
107
|
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
The launch flag is what activates channel mode; plain `claude` treats the binary as an ordinary MCP server and never opens the channel. Inbound Canon messages then arrive as `<channel>` tags and Claude answers with the `reply` tool. Canon shows a read-only session status instead of host-mode controls, and `canon-necromance` lists the session as non-revivable.
|
|
109
|
+
|
|
110
|
+
If you registered a profile with `canon-register`, pin it instead of passing the key — the stored profile carries its own environment binding:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"mcpServers": {
|
|
115
|
+
"canon-channel": {
|
|
116
|
+
"command": "canon-channel-server",
|
|
117
|
+
"env": { "CANON_AGENT": "my-agent" }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
89
121
|
```
|
|
90
122
|
|
|
91
|
-
|
|
123
|
+
`CANON_PLUGIN_API_KEY` is accepted wherever `CANON_API_KEY` is; the bundled Claude Code plugin manifest sets it from user config. `CANON_ENVIRONMENT_ID` is still required with either variable.
|
|
124
|
+
|
|
125
|
+
`canon-setup` installs the bundled `/canon-register` and `/canon-configure` skills into `~/.claude/skills` and prints this MCP configuration.
|
|
126
|
+
|
|
127
|
+
Use `canon-claude` host mode when you want phone-controlled runtime sessions with setup and live controls.
|
|
92
128
|
|
|
93
129
|
## Development
|
|
94
130
|
|
package/dist/approval-server.js
CHANGED
|
@@ -131,8 +131,9 @@ export class ApprovalHttpServer {
|
|
|
131
131
|
}
|
|
132
132
|
const toolName = payload.tool_name ?? 'Unknown';
|
|
133
133
|
const toolInput = payload.tool_input ?? {};
|
|
134
|
-
//
|
|
135
|
-
const
|
|
134
|
+
// Single source of truth for risk, shared with host mode (tool-policy.ts).
|
|
135
|
+
const risk = classifyClaudeApprovalRisk(toolName, toolInput);
|
|
136
|
+
const riskLevel = risk === 'destructive' ? 'destructive' : 'normal';
|
|
136
137
|
const isOwnerTurn = this.getIsOwnerTurn();
|
|
137
138
|
// Find the conversation to send the approval to.
|
|
138
139
|
const conversationId = this.getConversationId();
|
|
@@ -154,7 +155,7 @@ export class ApprovalHttpServer {
|
|
|
154
155
|
try {
|
|
155
156
|
const result = await this.manager.requestApproval(conversationId, toolName, toolInput, {
|
|
156
157
|
riskLevel,
|
|
157
|
-
risk
|
|
158
|
+
risk,
|
|
158
159
|
category: classifyClaudeApprovalCategory(toolName),
|
|
159
160
|
native: {
|
|
160
161
|
runtime: 'claude-code',
|
|
@@ -182,19 +183,4 @@ export class ApprovalHttpServer {
|
|
|
182
183
|
},
|
|
183
184
|
}));
|
|
184
185
|
}
|
|
185
|
-
classifyRisk(toolName, toolInput) {
|
|
186
|
-
if (toolName !== 'Bash')
|
|
187
|
-
return 'normal';
|
|
188
|
-
const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
189
|
-
const destructivePatterns = [
|
|
190
|
-
/\brm\s+(-rf?|--force)/,
|
|
191
|
-
/\bgit\s+push\s+--force/,
|
|
192
|
-
/\bgit\s+reset\s+--hard/,
|
|
193
|
-
/\bdrop\s+(table|database)/i,
|
|
194
|
-
/\bformat\b/,
|
|
195
|
-
];
|
|
196
|
-
return destructivePatterns.some((p) => p.test(cmd))
|
|
197
|
-
? 'destructive'
|
|
198
|
-
: 'normal';
|
|
199
|
-
}
|
|
200
186
|
}
|
package/dist/host.js
CHANGED
|
@@ -27,16 +27,14 @@ import { existsSync } from 'node:fs';
|
|
|
27
27
|
import { readFile } from 'node:fs/promises';
|
|
28
28
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
29
29
|
import { isAnthropicImageAttachment, materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
30
|
-
import { captureTurnArtifactSnapshot, collectTurnArtifacts, IDLE_TIMEOUT_MS, } from '@canonmsg/coding-agent-host';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import { runCli } from './cli-entry.js';
|
|
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, buildBoundedTurnTrail, createConversationMetadataLoader, createTurnOutputController, createRuntimeStatePublisher, createTypingStatusPublisher, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, ControlChannelPoller, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ApprovalManager, RuntimeRequestManager, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, verifyResolvedAgentEnvironment, decideAutoReply, initRTDBAuth, isChunkedSendMessageError, sendMessageWithRetry, sendMessageWithRetryChunked, loadHostSessionConfig, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, renderCodingHostInboundPrompt, resolveHostWorkspaceCwd, shouldTriggerAgentTurn, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
32
|
+
import { runCli } from '@canonmsg/core';
|
|
34
33
|
import { CANON_VERB_MCP_SERVER_NAME, createCanonVerbMcpServer } from '@canonmsg/agent-tools';
|
|
35
34
|
import { synthesizeClaudeApprovalDiff } from './approval-diff.js';
|
|
36
35
|
import { decideClaudeToolPermissionForMode, parseAllowedNonOwnerClaudeTools, } from './tool-policy.js';
|
|
37
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, prepareTurnTrailForDelivery, rememberDispatchedClaudeInput, resetClaudeTurnActivityState, shouldOpenClaudeTurnOnRunning, 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';
|
|
38
37
|
import { CLAUDE_SUPPORTED_DIALOG_KINDS, buildClaudeAskUserPermissionDenied, buildClaudeAskUserPermissionResult, createClaudeUserDialogCoordinator, parseClaudeAskUserDialog, parseClaudeAskUserToolInput, resolveClaudeUserDialogRequestId, } from './user-dialog.js';
|
|
39
|
-
import { collectMissedInboundMessages, createReconnectRecoveryGate, STARTUP_RECOVERY_MAX_MESSAGES, STARTUP_RECOVERY_PAGE_SIZE, } from './startup-recovery.js';
|
|
40
38
|
function parseRuntimeVisibilityPreset(value) {
|
|
41
39
|
return value === 'normal' || value === 'minimal' || value === 'full' ? value : undefined;
|
|
42
40
|
}
|
|
@@ -194,7 +192,7 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
194
192
|
label: 'Level',
|
|
195
193
|
kind: 'enum',
|
|
196
194
|
required: true,
|
|
197
|
-
choices: [...
|
|
195
|
+
choices: [...EFFORT_OPTIONS],
|
|
198
196
|
}],
|
|
199
197
|
dispatch: {
|
|
200
198
|
kind: 'control',
|
|
@@ -237,7 +235,7 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
237
235
|
executionModes: input.executionModes,
|
|
238
236
|
permissionModes: CLAUDE_PERMISSION_MODE_OPTIONS,
|
|
239
237
|
defaultPermissionMode: 'default',
|
|
240
|
-
effortOptions: [...
|
|
238
|
+
effortOptions: [...EFFORT_OPTIONS],
|
|
241
239
|
extraRuntimeControls: [{
|
|
242
240
|
id: 'ultracode',
|
|
243
241
|
label: 'Ultracode',
|
|
@@ -272,7 +270,7 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
272
270
|
commands,
|
|
273
271
|
});
|
|
274
272
|
}
|
|
275
|
-
const CLAUDE_EFFORT_VALUES = new Set(
|
|
273
|
+
const CLAUDE_EFFORT_VALUES = new Set(EFFORT_OPTIONS.map((option) => option.value));
|
|
276
274
|
function parseClaudePermissionMode(value) {
|
|
277
275
|
if (value === 'acceptEdits'
|
|
278
276
|
|| value === 'auto'
|
|
@@ -376,13 +374,14 @@ function acceptClaudeCli(source, cliPath) {
|
|
|
376
374
|
function toModelOptions(models) {
|
|
377
375
|
return models.map(claudeModelInfoToOption);
|
|
378
376
|
}
|
|
379
|
-
async function publishAgentRuntime(agentId, runtime) {
|
|
380
|
-
await publishHostAgentRuntime(agentId, 'claude-code', runtime);
|
|
377
|
+
async function publishAgentRuntime(agentId, runtime, rtdb) {
|
|
378
|
+
await publishHostAgentRuntime(agentId, 'claude-code', runtime, rtdb);
|
|
381
379
|
}
|
|
382
|
-
async function loadSessionConfig(conversationId, agentId) {
|
|
380
|
+
async function loadSessionConfig(conversationId, agentId, rtdb) {
|
|
383
381
|
const config = await loadHostSessionConfig({
|
|
384
382
|
conversationId,
|
|
385
383
|
agentId,
|
|
384
|
+
rtdb,
|
|
386
385
|
extraStringFields: ['permissionMode', 'effort'],
|
|
387
386
|
});
|
|
388
387
|
return {
|
|
@@ -1482,13 +1481,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
1482
1481
|
const deliveredMessageIds = chunkedFailure
|
|
1483
1482
|
? chunkedFailure.deliveredMessageIds
|
|
1484
1483
|
: resume.deliveredMessageIds;
|
|
1485
|
-
const failure = classifyFinalDeliveryFailure(cause
|
|
1486
|
-
if (failure === 'already-delivered') {
|
|
1487
|
-
// The server's idempotency guard says this final is already durable.
|
|
1488
|
-
markFinalTurnDelivered(deliverTurn);
|
|
1489
|
-
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Final reply was already delivered under this id`);
|
|
1490
|
-
return true;
|
|
1491
|
-
}
|
|
1484
|
+
const failure = classifyFinalDeliveryFailure(cause);
|
|
1492
1485
|
if (failure === 'permanent') {
|
|
1493
1486
|
// Re-sending is pointless — the same request fails the same way. Surface
|
|
1494
1487
|
// what can be surfaced and finish the turn instead of burning the retry
|
|
@@ -2089,6 +2082,7 @@ function createSession(conversationId, environment, agentId, client, typingSigna
|
|
|
2089
2082
|
if (!shouldApplyClaudeEchoedSessionState({
|
|
2090
2083
|
echoed: state,
|
|
2091
2084
|
dispatching: session.dispatchingInput !== null,
|
|
2085
|
+
active: session.activeInput !== null,
|
|
2092
2086
|
hasPendingFinalDelivery: Boolean(session.pendingFinalDelivery),
|
|
2093
2087
|
})) {
|
|
2094
2088
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] `
|
|
@@ -2420,6 +2414,7 @@ export async function main() {
|
|
|
2420
2414
|
agentId,
|
|
2421
2415
|
clientType: 'claude-code',
|
|
2422
2416
|
hostMode: true,
|
|
2417
|
+
rtdb,
|
|
2423
2418
|
});
|
|
2424
2419
|
let streamConnected = false;
|
|
2425
2420
|
const hostAvailableExecutionModes = [
|
|
@@ -2460,6 +2455,7 @@ export async function main() {
|
|
|
2460
2455
|
await publishHostSessionSnapshots({
|
|
2461
2456
|
conversationIds,
|
|
2462
2457
|
agentId,
|
|
2458
|
+
rtdb,
|
|
2463
2459
|
clientType: 'claude-code',
|
|
2464
2460
|
runtime: runtimeDescriptor,
|
|
2465
2461
|
workspaceOptions,
|
|
@@ -2504,7 +2500,7 @@ export async function main() {
|
|
|
2504
2500
|
console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
|
|
2505
2501
|
return runtimeMetadata;
|
|
2506
2502
|
});
|
|
2507
|
-
await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
|
|
2503
|
+
await publishAgentRuntime(agentId, runtimeDescriptor, rtdb).catch((error) => {
|
|
2508
2504
|
console.error('[canon-host] Failed to publish agent runtime:', error);
|
|
2509
2505
|
});
|
|
2510
2506
|
await publishSessionSnapshots(Array.from(knownConversationIds));
|
|
@@ -2865,7 +2861,7 @@ export async function main() {
|
|
|
2865
2861
|
evictOldestIdle();
|
|
2866
2862
|
}
|
|
2867
2863
|
const creation = (async () => {
|
|
2868
|
-
const config = await loadSessionConfig(conversationId, agentId);
|
|
2864
|
+
const config = await loadSessionConfig(conversationId, agentId, rtdb);
|
|
2869
2865
|
const sessionExecutionMode = resolveSessionExecutionMode(config);
|
|
2870
2866
|
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
2871
2867
|
const environment = prepareConversationEnvironment({
|
|
@@ -3119,40 +3115,21 @@ export async function main() {
|
|
|
3119
3115
|
}
|
|
3120
3116
|
return consumed;
|
|
3121
3117
|
}
|
|
3122
|
-
function
|
|
3123
|
-
|
|
3118
|
+
function persistedConversationCursor(conversationId) {
|
|
3119
|
+
return loadRuntimeSessionState(runtimeId, {
|
|
3124
3120
|
conversationId,
|
|
3125
3121
|
baseCwd: workingDir,
|
|
3126
3122
|
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
3127
3123
|
})?.lastInboundMessageId ?? null;
|
|
3128
|
-
if (canonicalCursor)
|
|
3129
|
-
return { canonicalCursor, legacyCursors: [] };
|
|
3130
|
-
const legacyCursors = Object.values(readLocalRuntimeEntry(runtimeId)?.sessions ?? {})
|
|
3131
|
-
.filter((state) => state.conversationId === conversationId)
|
|
3132
|
-
.map((state) => state.lastInboundMessageId)
|
|
3133
|
-
.filter((messageId) => Boolean(messageId));
|
|
3134
|
-
return { canonicalCursor: null, legacyCursors: [...new Set(legacyCursors)] };
|
|
3135
3124
|
}
|
|
3136
3125
|
async function performMissedInboundRecovery() {
|
|
3137
3126
|
for (const conversationId of knownConversationIds) {
|
|
3138
3127
|
try {
|
|
3139
|
-
const cursors = persistedConversationCursors(conversationId);
|
|
3140
3128
|
const recovered = await collectMissedInboundMessages({
|
|
3141
3129
|
fetchPage: (before) => client.getMessagesPage(conversationId, STARTUP_RECOVERY_PAGE_SIZE, before),
|
|
3142
|
-
cursor:
|
|
3143
|
-
cursorCandidates: cursors.legacyCursors,
|
|
3130
|
+
cursor: persistedConversationCursor(conversationId),
|
|
3144
3131
|
agentId,
|
|
3145
3132
|
});
|
|
3146
|
-
if (!cursors.canonicalCursor
|
|
3147
|
-
&& recovered.cursor
|
|
3148
|
-
&& recovered.messages.length === 0) {
|
|
3149
|
-
saveRuntimeSessionState(runtimeId, {
|
|
3150
|
-
conversationId,
|
|
3151
|
-
baseCwd: workingDir,
|
|
3152
|
-
workspaceId: CLAUDE_RECOVERY_CURSOR_WORKSPACE_ID,
|
|
3153
|
-
lastInboundMessageId: recovered.cursor,
|
|
3154
|
-
});
|
|
3155
|
-
}
|
|
3156
3133
|
if (recovered.mode === 'truncated-window') {
|
|
3157
3134
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Recovery cursor was not found within ${STARTUP_RECOVERY_MAX_MESSAGES} messages; replaying the bounded recent window`);
|
|
3158
3135
|
}
|
package/dist/register.d.ts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI script for Canon agent registration.
|
|
4
|
+
* Invoked by the /canon-register skill.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node register.js --name "My Agent" --description "What it does" --phone "+15551234567" [--profile "my-agent"]
|
|
8
|
+
*
|
|
9
|
+
* On approval, saves the agent to ~/.canon/agents.json under the profile name.
|
|
10
|
+
*/
|
|
2
11
|
export declare function main(): Promise<void>;
|
package/dist/register.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { setDefaultResultOrder } from 'node:dns';
|
|
3
2
|
/**
|
|
4
3
|
* CLI script for Canon agent registration.
|
|
5
4
|
* Invoked by the /canon-register skill.
|
|
@@ -9,9 +8,7 @@ import { setDefaultResultOrder } from 'node:dns';
|
|
|
9
8
|
*
|
|
10
9
|
* On approval, saves the agent to ~/.canon/agents.json under the profile name.
|
|
11
10
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import { parseArgs } from 'node:util';
|
|
14
|
-
import { runCli } from './cli-entry.js';
|
|
11
|
+
import { registerRegistrationCli, runRegistrationCli, } from '@canonmsg/core';
|
|
15
12
|
const HELP = `canon-register — register or reconnect a Claude Code agent in Canon
|
|
16
13
|
|
|
17
14
|
USAGE
|
|
@@ -37,114 +34,17 @@ EXAMPLES
|
|
|
37
34
|
canon-register --name "Frontend" --description "React work" --phone "+15551234567" --profile frontend
|
|
38
35
|
|
|
39
36
|
After approval, start it with CANON_AGENT=<profile> canon-claude --cwd /path/to/project.`;
|
|
37
|
+
const OPTIONS = {
|
|
38
|
+
moduleUrl: import.meta.url,
|
|
39
|
+
clientType: 'claude-code',
|
|
40
|
+
cliName: 'canon-register',
|
|
41
|
+
hostBinName: 'canon-claude',
|
|
42
|
+
developerInfo: 'Claude Code plugin',
|
|
43
|
+
registeringLabel: 'agent',
|
|
44
|
+
usage: 'Usage: node register.js --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]',
|
|
45
|
+
help: HELP,
|
|
46
|
+
};
|
|
40
47
|
export async function main() {
|
|
41
|
-
|
|
42
|
-
const { values } = parseArgs({
|
|
43
|
-
options: {
|
|
44
|
-
name: { type: 'string' },
|
|
45
|
-
description: { type: 'string' },
|
|
46
|
-
phone: { type: 'string' },
|
|
47
|
-
profile: { type: 'string' },
|
|
48
|
-
environment: { type: 'string' },
|
|
49
|
-
'base-url': { type: 'string' },
|
|
50
|
-
'stream-url': { type: 'string' },
|
|
51
|
-
'rtdb-url': { type: 'string' },
|
|
52
|
-
'firebase-api-key': { type: 'string' },
|
|
53
|
-
},
|
|
54
|
-
strict: true,
|
|
55
|
-
});
|
|
56
|
-
if (!values.name || !values.description || !values.phone) {
|
|
57
|
-
console.error('Usage: node register.js --name "Agent Name" --description "Description" --phone "+15551234567" [--profile "my-agent"]');
|
|
58
|
-
process.exit(1);
|
|
59
|
-
}
|
|
60
|
-
// Default profile name: sanitized agent name
|
|
61
|
-
const profileName = values.profile || values.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
|
|
62
|
-
const environmentId = values.environment || process.env.CANON_ENVIRONMENT_ID;
|
|
63
|
-
if (!environmentId) {
|
|
64
|
-
throw new Error('--environment or CANON_ENVIRONMENT_ID is required');
|
|
65
|
-
}
|
|
66
|
-
const connection = resolveCanonRuntimeConnection({
|
|
67
|
-
environmentId,
|
|
68
|
-
apiBaseUrl: values['base-url'],
|
|
69
|
-
streamUrl: values['stream-url'],
|
|
70
|
-
rtdbUrl: values['rtdb-url'],
|
|
71
|
-
firebaseWebApiKey: values['firebase-api-key'],
|
|
72
|
-
});
|
|
73
|
-
await verifyCanonRuntimeConnection(connection);
|
|
74
|
-
const existingProfile = loadProfiles()[profileName];
|
|
75
|
-
const existingAgentId = existingProfile?.environmentId === connection.environmentId
|
|
76
|
-
? existingProfile.agentId
|
|
77
|
-
: undefined;
|
|
78
|
-
console.log(`Registering agent "${values.name}" (profile: ${profileName})...`);
|
|
79
|
-
const pending = getOrCreatePendingRegistration(profileName, 'claude-code', connection);
|
|
80
|
-
const result = await registerAndWaitForApproval({
|
|
81
|
-
name: values.name,
|
|
82
|
-
description: values.description,
|
|
83
|
-
ownerPhone: values.phone,
|
|
84
|
-
developerInfo: 'Claude Code plugin',
|
|
85
|
-
clientType: 'claude-code',
|
|
86
|
-
baseUrl: connection.apiBaseUrl,
|
|
87
|
-
requestedAgentId: existingAgentId,
|
|
88
|
-
localRegistrationId: pending.localRegistrationId,
|
|
89
|
-
}, {
|
|
90
|
-
onSubmitted: (requestId, pollToken) => {
|
|
91
|
-
updatePendingRegistration(profileName, {
|
|
92
|
-
requestId,
|
|
93
|
-
pollToken,
|
|
94
|
-
clientType: 'claude-code',
|
|
95
|
-
});
|
|
96
|
-
console.log(`Registration submitted (request ID: ${requestId}).`);
|
|
97
|
-
console.log('Waiting for approval in Canon app...');
|
|
98
|
-
},
|
|
99
|
-
onPollUpdate: () => {
|
|
100
|
-
process.stdout.write('.');
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
console.log(''); // newline after dots
|
|
104
|
-
switch (result.status) {
|
|
105
|
-
case 'approved': {
|
|
106
|
-
if (!result.apiKey || !result.agentId || !result.agentName) {
|
|
107
|
-
console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
|
|
108
|
-
process.exit(1);
|
|
109
|
-
}
|
|
110
|
-
upsertAgentProfile(profileName, {
|
|
111
|
-
apiKey: result.apiKey,
|
|
112
|
-
agentId: result.agentId,
|
|
113
|
-
agentName: result.agentName,
|
|
114
|
-
registeredAt: new Date().toISOString(),
|
|
115
|
-
environmentId: connection.environmentId,
|
|
116
|
-
baseUrl: connection.apiBaseUrl,
|
|
117
|
-
streamUrl: connection.streamUrl,
|
|
118
|
-
rtdbUrl: connection.rtdbUrl,
|
|
119
|
-
firebaseApiKey: connection.firebaseWebApiKey,
|
|
120
|
-
clientType: 'claude-code',
|
|
121
|
-
});
|
|
122
|
-
if (result.requestId) {
|
|
123
|
-
await ackRegistrationApproval(connection.apiBaseUrl, result.requestId, result.pollToken);
|
|
124
|
-
}
|
|
125
|
-
clearPendingRegistration(profileName);
|
|
126
|
-
console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
|
|
127
|
-
console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
|
|
128
|
-
console.log('Start it with: CANON_AGENT=' + profileName + ' canon-claude --cwd /path/to/project');
|
|
129
|
-
console.log('Keep that terminal open while you want Canon to reach the agent.');
|
|
130
|
-
console.log('Closing it, logging out, rebooting, or sleeping long enough to stop the process takes this local agent offline until you revive it.');
|
|
131
|
-
console.log('Docs: https://canonmail.com/agents/integrations');
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
134
|
-
case 'rejected':
|
|
135
|
-
console.log('Registration was rejected.');
|
|
136
|
-
process.exit(1);
|
|
137
|
-
break;
|
|
138
|
-
case 'timeout':
|
|
139
|
-
console.log('Registration timed out (5 minutes). Try again later.');
|
|
140
|
-
process.exit(1);
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
48
|
+
await runRegistrationCli(OPTIONS);
|
|
143
49
|
}
|
|
144
|
-
|
|
145
|
-
console.error('[canon-register] Fatal error:', error);
|
|
146
|
-
process.exit(1);
|
|
147
|
-
}, {
|
|
148
|
-
name: 'canon-register',
|
|
149
|
-
help: HELP,
|
|
150
|
-
});
|
|
50
|
+
registerRegistrationCli(OPTIONS);
|
package/dist/server.js
CHANGED
|
@@ -10,10 +10,10 @@ import { setDefaultResultOrder } from 'node:dns';
|
|
|
10
10
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
11
11
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
12
12
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
13
|
-
import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, verifyResolvedAgentEnvironment, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth,
|
|
13
|
+
import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, verifyResolvedAgentEnvironment, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, renderCanonHostInboundContent, } from '@canonmsg/core';
|
|
14
14
|
import { materializeMessageMedia, sendMediaFileMessage, } from '@canonmsg/agent-sdk';
|
|
15
15
|
import { ApprovalHttpServer } from './approval-server.js';
|
|
16
|
-
import { runCli } from '
|
|
16
|
+
import { runCli } from '@canonmsg/core';
|
|
17
17
|
import { parseReplyArgs, parseSendMessageArgs, parseSetTypingArgs, } from './mcp-args.js';
|
|
18
18
|
import { canonVerbToolDefinitions, executeCanonVerbTool, isCanonToolVerb, stampSendToTurnComplete, } from '@canonmsg/agent-tools';
|
|
19
19
|
const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
|
|
@@ -39,6 +39,7 @@ let stream = null;
|
|
|
39
39
|
let agentContext = null;
|
|
40
40
|
let approvalManager = null;
|
|
41
41
|
let approvalServer = null;
|
|
42
|
+
let rtdb = null;
|
|
42
43
|
const conversationCache = new Map();
|
|
43
44
|
const TURN_COMPLETE_METADATA = {
|
|
44
45
|
turnSemantics: 'turn_complete',
|
|
@@ -486,7 +487,7 @@ async function startChannel() {
|
|
|
486
487
|
}
|
|
487
488
|
}
|
|
488
489
|
// Initialize RTDB auth for session state reporting
|
|
489
|
-
initRTDBAuth(client, {
|
|
490
|
+
rtdb = initRTDBAuth(client, {
|
|
490
491
|
rtdbUrl: resolvedRuntime.rtdbUrl,
|
|
491
492
|
firebaseApiKey: resolvedRuntime.firebaseApiKey,
|
|
492
493
|
});
|
|
@@ -524,7 +525,7 @@ async function startChannel() {
|
|
|
524
525
|
// Write initial session state
|
|
525
526
|
const initialState = { cwd: process.cwd(), isActive: true };
|
|
526
527
|
for (const convoId of conversationCache.keys()) {
|
|
527
|
-
writeSessionState(convoId, agentId, initialState).catch((err) => console.error(`[canon] Failed to write initial state for ${convoId}:`, err));
|
|
528
|
+
rtdb?.writeSessionState(convoId, agentId, initialState).catch((err) => console.error(`[canon] Failed to write initial state for ${convoId}:`, err));
|
|
528
529
|
}
|
|
529
530
|
// Start SSE stream
|
|
530
531
|
stream = new CanonStream({
|
|
@@ -580,7 +581,7 @@ export async function main() {
|
|
|
580
581
|
// Mark all conversations as inactive
|
|
581
582
|
if (agentContext) {
|
|
582
583
|
for (const convoId of conversationCache.keys()) {
|
|
583
|
-
clearSessionState(convoId, agentContext.agentId).catch(() => { });
|
|
584
|
+
rtdb?.clearSessionState(convoId, agentContext.agentId).catch(() => { });
|
|
584
585
|
}
|
|
585
586
|
}
|
|
586
587
|
approvalManager?.dispose();
|
package/dist/session-state.d.ts
CHANGED
|
@@ -165,6 +165,8 @@ export declare function shouldApplyClaudeEchoedSessionState(input: {
|
|
|
165
165
|
echoed: ClaudeSessionRunState | undefined;
|
|
166
166
|
/** An input is mid-dispatch: reserved by the host, not yet with the SDK. */
|
|
167
167
|
dispatching: boolean;
|
|
168
|
+
/** The SDK has accepted a Canon input whose turn has not completed. */
|
|
169
|
+
active: boolean;
|
|
168
170
|
/** A final is still being handed to Canon; the turn is not over. */
|
|
169
171
|
hasPendingFinalDelivery: boolean;
|
|
170
172
|
}): boolean;
|
|
@@ -478,34 +480,19 @@ export declare function composeClaudeFinalText(input: {
|
|
|
478
480
|
streamedText?: string | null;
|
|
479
481
|
failureNotice?: string | null;
|
|
480
482
|
}): string | null;
|
|
481
|
-
export type ClaudeFinalDeliveryFailure = 'retry' | 'permanent'
|
|
483
|
+
export type ClaudeFinalDeliveryFailure = 'retry' | 'permanent';
|
|
482
484
|
/**
|
|
483
485
|
* How the host should react to a failed final-reply send.
|
|
484
486
|
*
|
|
485
487
|
* Core's `isRetryableCanonDeliveryError` is already right about what a retry
|
|
486
488
|
* can fix (429, 5xx, transport errors); what the host lacked was the other
|
|
487
489
|
* half — everything else is PERMANENT and must be surfaced instead of retried.
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
* a pure hash of agent + conversation + turnKey, an id can be taken by an
|
|
495
|
-
* ENTIRELY DIFFERENT answer to the same inbound message, written by a previous
|
|
496
|
-
* process. Reading that as "the final is delivered" would finalize the turn
|
|
497
|
-
* with the tail never sent, so it stays 'permanent': a stops-short notice beats
|
|
498
|
-
* silently dropping the end of an answer.
|
|
499
|
-
*
|
|
500
|
-
* Resume support does not soften this. Core absorbs the one conflict that IS
|
|
501
|
-
* provably ours — the first part of a resumed attempt, whose write may have
|
|
502
|
-
* landed before its response was lost — and reports every other one, so a
|
|
503
|
-
* conflict reaching this function is precisely the case that must not be read
|
|
504
|
-
* as success.
|
|
505
|
-
*/
|
|
506
|
-
export declare function classifyFinalDeliveryFailure(error: unknown, options?: {
|
|
507
|
-
chunked?: boolean;
|
|
508
|
-
}): ClaudeFinalDeliveryFailure;
|
|
490
|
+
* Exact message-id replays return success with `created: false`. A 409 from the
|
|
491
|
+
* server's idempotency guard therefore means the stored payload differs from
|
|
492
|
+
* this final and is permanent; treating it as delivered would retire a turn
|
|
493
|
+
* whose answer was never accepted.
|
|
494
|
+
*/
|
|
495
|
+
export declare function classifyFinalDeliveryFailure(error: unknown): ClaudeFinalDeliveryFailure;
|
|
509
496
|
/**
|
|
510
497
|
* The pending-delivery record for a final that has to be retried, carrying the
|
|
511
498
|
* progress the next attempt needs.
|
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,
|
|
3
|
+
import { DEFAULT_CHUNKED_MESSAGE_TEXT_MAX_BYTES, isRetryableCanonDeliveryError, 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;
|
|
@@ -163,7 +163,7 @@ export function isClaudeTurnSlotReserved(state) {
|
|
|
163
163
|
export function shouldApplyClaudeEchoedSessionState(input) {
|
|
164
164
|
if (input.echoed !== 'idle')
|
|
165
165
|
return true;
|
|
166
|
-
return !input.dispatching && !input.hasPendingFinalDelivery;
|
|
166
|
+
return !input.dispatching && !input.active && !input.hasPendingFinalDelivery;
|
|
167
167
|
}
|
|
168
168
|
/** Claim the slot for an input about to be dispatched. Must precede any await. */
|
|
169
169
|
export function reserveClaudeTurnSlot(session) {
|
|
@@ -775,28 +775,12 @@ export function composeClaudeFinalText(input) {
|
|
|
775
775
|
* Core's `isRetryableCanonDeliveryError` is already right about what a retry
|
|
776
776
|
* can fix (429, 5xx, transport errors); what the host lacked was the other
|
|
777
777
|
* half — everything else is PERMANENT and must be surfaced instead of retried.
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
* sequential messages that abort on the first failure, so a 409 says only that
|
|
783
|
-
* ONE part id is taken, with nothing to say which — and since the part ids are
|
|
784
|
-
* a pure hash of agent + conversation + turnKey, an id can be taken by an
|
|
785
|
-
* ENTIRELY DIFFERENT answer to the same inbound message, written by a previous
|
|
786
|
-
* process. Reading that as "the final is delivered" would finalize the turn
|
|
787
|
-
* with the tail never sent, so it stays 'permanent': a stops-short notice beats
|
|
788
|
-
* silently dropping the end of an answer.
|
|
789
|
-
*
|
|
790
|
-
* Resume support does not soften this. Core absorbs the one conflict that IS
|
|
791
|
-
* provably ours — the first part of a resumed attempt, whose write may have
|
|
792
|
-
* landed before its response was lost — and reports every other one, so a
|
|
793
|
-
* conflict reaching this function is precisely the case that must not be read
|
|
794
|
-
* as success.
|
|
778
|
+
* Exact message-id replays return success with `created: false`. A 409 from the
|
|
779
|
+
* server's idempotency guard therefore means the stored payload differs from
|
|
780
|
+
* this final and is permanent; treating it as delivered would retire a turn
|
|
781
|
+
* whose answer was never accepted.
|
|
795
782
|
*/
|
|
796
|
-
export function classifyFinalDeliveryFailure(error
|
|
797
|
-
if (isCanonMessageIdConflict(error) && !options.chunked) {
|
|
798
|
-
return 'already-delivered';
|
|
799
|
-
}
|
|
783
|
+
export function classifyFinalDeliveryFailure(error) {
|
|
800
784
|
return isRetryableCanonDeliveryError(error) ? 'retry' : 'permanent';
|
|
801
785
|
}
|
|
802
786
|
/**
|
package/dist/setup.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
|
10
10
|
import { join, dirname } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { runCli } from '
|
|
12
|
+
import { runCli } from '@canonmsg/core';
|
|
13
13
|
const HELP = `canon-setup — install Claude Code Canon skills and print MCP setup
|
|
14
14
|
|
|
15
15
|
USAGE
|
|
@@ -47,23 +47,21 @@ function installSkills() {
|
|
|
47
47
|
}
|
|
48
48
|
// ── Show MCP config instructions ───────────────────────────────────────
|
|
49
49
|
function showMcpInstructions() {
|
|
50
|
-
// Find the installed server.js path
|
|
51
|
-
const serverPath = join(__dirname, 'server.js');
|
|
52
50
|
console.log(`
|
|
53
51
|
Add this to your project's .mcp.json (or ~/.mcp.json for global):
|
|
54
52
|
|
|
55
53
|
{
|
|
56
54
|
"mcpServers": {
|
|
57
55
|
"canon-channel": {
|
|
58
|
-
"command": "
|
|
59
|
-
"args": ["${serverPath}"]
|
|
56
|
+
"command": "canon-channel-server"
|
|
60
57
|
}
|
|
61
58
|
}
|
|
62
59
|
}
|
|
63
60
|
|
|
64
61
|
Then start Claude Code with:
|
|
65
62
|
|
|
66
|
-
|
|
63
|
+
CANON_API_KEY=agk_live_... CANON_ENVIRONMENT_ID=canon-prod-v1 \
|
|
64
|
+
claude --dangerously-load-development-channels server:canon-channel
|
|
67
65
|
`);
|
|
68
66
|
}
|
|
69
67
|
// ── Main ───────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.29.
|
|
3
|
+
"version": "0.29.2",
|
|
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": "^
|
|
35
|
-
"@canonmsg/agent-tools": "^0.3.
|
|
36
|
-
"@canonmsg/coding-agent-host": "^0.
|
|
37
|
-
"@canonmsg/core": "^
|
|
38
|
-
"@canonmsg/rich-cards": "^0.8.
|
|
34
|
+
"@canonmsg/agent-sdk": "^8.0.0",
|
|
35
|
+
"@canonmsg/agent-tools": "^0.3.3",
|
|
36
|
+
"@canonmsg/coding-agent-host": "^0.4.0",
|
|
37
|
+
"@canonmsg/core": "^9.0.0",
|
|
38
|
+
"@canonmsg/rich-cards": "^0.8.7",
|
|
39
39
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
40
40
|
},
|
|
41
41
|
"engines": {
|
|
@@ -1,50 +1,59 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: canon-configure
|
|
3
|
-
description:
|
|
3
|
+
description: Use an existing Canon agent API key with Claude Code
|
|
4
4
|
user-invocable: true
|
|
5
5
|
allowed-tools:
|
|
6
6
|
- Read
|
|
7
7
|
- Write
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
#
|
|
10
|
+
# Use an existing Canon API key
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Point Claude Code at a Canon agent key the user already has.
|
|
13
|
+
|
|
14
|
+
## Hard rule
|
|
15
|
+
|
|
16
|
+
Never write or edit `~/.canon/agents.json`. A stored profile must carry the agent
|
|
17
|
+
identity **and** a complete environment-bound endpoint set (`agentId`,
|
|
18
|
+
`environmentId`, `baseUrl`, `streamUrl`, `rtdbUrl`, `firebaseApiKey`), and every
|
|
19
|
+
entry is validated in one pass on load — a single hand-written entry makes the
|
|
20
|
+
whole file unloadable for every Canon host on that machine. Only the register
|
|
21
|
+
CLI writes profiles.
|
|
13
22
|
|
|
14
23
|
## Steps
|
|
15
24
|
|
|
16
25
|
1. Ask the user for:
|
|
17
|
-
- **API key** —
|
|
18
|
-
- **
|
|
19
|
-
- **Agent name** (optional) — display name for reference
|
|
20
|
-
|
|
21
|
-
2. Validate the API key format — it must start with `agk_live_` and be non-empty.
|
|
26
|
+
- **API key** — starts with `agk_live_`
|
|
27
|
+
- **Canon environment** — `canon-prod-v1` for production, `canon-dev-v1` for dev
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
2. Give them the launch command for the key. Nothing is stored; the key lives in
|
|
30
|
+
the environment of the process they start:
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{
|
|
28
|
-
"<profile>": {
|
|
29
|
-
"apiKey": "<key>",
|
|
30
|
-
"agentId": "",
|
|
31
|
-
"agentName": "<name or 'Unknown'>",
|
|
32
|
-
"registeredAt": "<current ISO date>"
|
|
33
|
-
}
|
|
34
|
-
}
|
|
32
|
+
```bash
|
|
33
|
+
CANON_API_KEY=<key> CANON_ENVIRONMENT_ID=<environment> canon-claude --cwd /path/to/project
|
|
35
34
|
```
|
|
36
35
|
|
|
37
|
-
|
|
36
|
+
`CANON_ENVIRONMENT_ID` is required with a raw key — without it the host exits
|
|
37
|
+
before connecting. Sessions started this way are shown as
|
|
38
|
+
manual/non-revivable by `canon-necromance`, because no secret-bearing state is
|
|
39
|
+
persisted.
|
|
38
40
|
|
|
39
|
-
|
|
41
|
+
3. If the user wants a stored, revivable profile instead, run `/canon-register`.
|
|
42
|
+
Registration is the only path that writes `~/.canon/agents.json`; re-running it
|
|
43
|
+
with an existing `--profile` name refreshes that profile's credential in place.
|
|
44
|
+
|
|
45
|
+
4. Once a profile exists, it can be pinned for channel mode in `.mcp.json`:
|
|
40
46
|
|
|
41
|
-
If they have multiple agents and want to pin one, add to `.mcp.json`:
|
|
42
47
|
```json
|
|
43
48
|
{
|
|
44
49
|
"mcpServers": {
|
|
45
50
|
"canon-channel": {
|
|
51
|
+
"command": "canon-channel-server",
|
|
46
52
|
"env": { "CANON_AGENT": "<profile>" }
|
|
47
53
|
}
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
```
|
|
57
|
+
|
|
58
|
+
`CANON_AGENT` is required as soon as more than one profile exists — the host
|
|
59
|
+
refuses to guess between them.
|
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 (
|
|
21
|
+
- **Canon environment** — The trust-domain ID supplied by Canon (`canon-prod-v1` for production, `canon-dev-v1` for dev). There is no CLI default; the command fails without it.
|
|
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:
|
|
@@ -26,18 +26,23 @@ Register a new Canon agent so it can send and receive messages. Each agent is sa
|
|
|
26
26
|
canon-register --environment "<environment>" --name "<name>" --description "<description>" --phone "<phone>" --profile "<profile>"
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
+
`CANON_ENVIRONMENT_ID=<environment>` in the environment is equivalent to `--environment`.
|
|
30
|
+
|
|
29
31
|
3. Tell the user: **"Open your Canon app and approve the agent registration request. Waiting for approval..."**
|
|
30
32
|
|
|
31
33
|
The CLI will poll automatically for up to 5 minutes.
|
|
32
34
|
|
|
33
35
|
4. On approval, the CLI saves the agent to `~/.canon/agents.json` automatically.
|
|
34
36
|
|
|
35
|
-
5. Tell the user: **"Agent registered!
|
|
37
|
+
5. Tell the user: **"Agent registered! Start it with `CANON_AGENT=<profile> canon-claude`. Or specify a project: `CANON_AGENT=<profile> canon-claude --cwd /path/to/project`"**
|
|
38
|
+
|
|
39
|
+
`CANON_AGENT` is required once more than one profile exists — the host refuses to guess between them.
|
|
36
40
|
|
|
37
41
|
## Error handling
|
|
38
42
|
|
|
39
43
|
- If the registration fails, show the error and ask the user to retry.
|
|
40
44
|
- If status returns `"rejected"`, tell the user the registration was rejected by the owner.
|
|
41
45
|
- If polling times out after 5 minutes, tell the user to try again later.
|
|
42
|
-
- If the phone number format
|
|
46
|
+
- If the server returns `Invalid phone number format`, the number could not be parsed as a real number; ask the user to re-enter it in full E.164 form (`+<country code><number>`).
|
|
47
|
+
- If the server returns `Owner phone number not found on platform`, the number is valid but no Canon account uses it; ask the user for the phone number of their Canon account.
|
|
43
48
|
- If environment verification fails, do not retry against another URL. Confirm the environment ID and endpoint set with the user.
|
package/dist/cli-entry.d.ts
DELETED
package/dist/cli-entry.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
|
package/dist/inbound-policy.d.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { type CanonGroupContext, type CanonGroupContextMode, type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
|
|
2
|
-
export interface InboundParticipantContext {
|
|
3
|
-
conversationType: CanonConversation['type'] | 'unknown';
|
|
4
|
-
memberCount: number | null;
|
|
5
|
-
senderType: 'human' | 'ai_agent';
|
|
6
|
-
senderName: string;
|
|
7
|
-
isOwner: boolean;
|
|
8
|
-
mentionedAgent: boolean;
|
|
9
|
-
groupContext?: CanonGroupContext;
|
|
10
|
-
groupContextMode?: CanonGroupContextMode;
|
|
11
|
-
recentSenderTypes: Array<'human' | 'ai_agent'>;
|
|
12
|
-
recentHumanCount: number;
|
|
13
|
-
recentAgentCount: number;
|
|
14
|
-
consecutiveAgentTurns: number;
|
|
15
|
-
currentAgentStreakStartedByHuman: boolean;
|
|
16
|
-
}
|
|
17
|
-
export interface AutoReplyDecision {
|
|
18
|
-
allow: boolean;
|
|
19
|
-
reason: string;
|
|
20
|
-
}
|
|
21
|
-
export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
|
|
22
|
-
export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
|
package/dist/inbound-policy.js
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import { buildCompactGroupContextLines, evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
|
|
2
|
-
function formatRecentSenders(senderTypes) {
|
|
3
|
-
if (senderTypes.length === 0)
|
|
4
|
-
return 'none';
|
|
5
|
-
return senderTypes.map((senderType) => (senderType === 'ai_agent' ? 'agent' : 'human')).join(' -> ');
|
|
6
|
-
}
|
|
7
|
-
export function buildInboundContextLines(context) {
|
|
8
|
-
const conversationTypeLabel = context.conversationType === 'unknown'
|
|
9
|
-
? 'unknown'
|
|
10
|
-
: `${context.conversationType}${context.memberCount ? ` (${context.memberCount} members)` : ''}`;
|
|
11
|
-
const senderRole = context.isOwner
|
|
12
|
-
? 'The latest sender is the verified human owner of this Canon agent.'
|
|
13
|
-
: context.senderType === 'ai_agent'
|
|
14
|
-
? 'The latest sender is another AI agent in Canon.'
|
|
15
|
-
: 'The latest sender is a human Canon participant.';
|
|
16
|
-
return [
|
|
17
|
-
senderRole,
|
|
18
|
-
`Latest sender name: ${context.senderName}`,
|
|
19
|
-
`Latest sender type: ${context.senderType}`,
|
|
20
|
-
`Conversation type: ${conversationTypeLabel}`,
|
|
21
|
-
...(context.groupContext && context.groupContextMode
|
|
22
|
-
? buildCompactGroupContextLines(context.groupContext, context.groupContextMode)
|
|
23
|
-
: []),
|
|
24
|
-
`Directly addressed to this agent: ${context.mentionedAgent ? 'yes' : 'no'}`,
|
|
25
|
-
`Recent sender pattern: ${formatRecentSenders(context.recentSenderTypes)}`,
|
|
26
|
-
`Recent human messages: ${context.recentHumanCount}`,
|
|
27
|
-
`Recent agent messages: ${context.recentAgentCount}`,
|
|
28
|
-
`Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
|
|
29
|
-
`Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
|
|
30
|
-
];
|
|
31
|
-
}
|
|
32
|
-
export function decideAutoReply(context, behavior) {
|
|
33
|
-
const decision = evaluateParticipationPolicy(behavior ?? resolveAgentBehaviorPolicy(), {
|
|
34
|
-
conversationType: context.conversationType,
|
|
35
|
-
senderType: context.senderType,
|
|
36
|
-
isOwner: context.isOwner,
|
|
37
|
-
mentionedAgent: context.mentionedAgent,
|
|
38
|
-
recentHumanCount: context.recentHumanCount,
|
|
39
|
-
consecutiveAgentTurns: context.consecutiveAgentTurns,
|
|
40
|
-
currentAgentStreakStartedByHuman: context.currentAgentStreakStartedByHuman,
|
|
41
|
-
});
|
|
42
|
-
return {
|
|
43
|
-
allow: decision.allow,
|
|
44
|
-
reason: decision.reason,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Startup recovery for inbound messages missed while the host was offline.
|
|
3
|
-
*
|
|
4
|
-
* The host persists a `lastInboundMessageId` cursor per conversation. On
|
|
5
|
-
* startup we paginate `getMessagesPage` (newest-first pages, older pages via
|
|
6
|
-
* its `before` message-id parameter) until the cursor is found or a hard
|
|
7
|
-
* per-conversation bound is hit, then replay everything after the cursor.
|
|
8
|
-
*
|
|
9
|
-
* Claude also accepts legacy cursor candidates so it can migrate old
|
|
10
|
-
* execution-mode-scoped state into one conversation-level recovery cursor.
|
|
11
|
-
*/
|
|
12
|
-
export declare const STARTUP_RECOVERY_PAGE_SIZE = 25;
|
|
13
|
-
export declare const STARTUP_RECOVERY_MAX_MESSAGES = 500;
|
|
14
|
-
export interface StartupRecoveryMessage {
|
|
15
|
-
id: string;
|
|
16
|
-
senderId: string;
|
|
17
|
-
createdAt?: string;
|
|
18
|
-
metadata?: Record<string, unknown>;
|
|
19
|
-
}
|
|
20
|
-
export interface StartupRecoveryPage {
|
|
21
|
-
messages: StartupRecoveryMessage[];
|
|
22
|
-
}
|
|
23
|
-
export type StartupRecoveryMode =
|
|
24
|
-
/** Cursor found — `messages` is everything strictly after it. */
|
|
25
|
-
'after-cursor'
|
|
26
|
-
/** Cursor present but not found within the bound — `messages` is the bounded recent window. */
|
|
27
|
-
| 'truncated-window'
|
|
28
|
-
/**
|
|
29
|
-
* No usable cursor (fresh runtime file, or the cursor message no longer
|
|
30
|
-
* exists in history) — only the newest inbound message is recovered, since
|
|
31
|
-
* a full-history replay could fire mass duplicate turns.
|
|
32
|
-
*/
|
|
33
|
-
| 'latest-only';
|
|
34
|
-
export interface StartupRecoveryResult<TPage extends StartupRecoveryPage> {
|
|
35
|
-
mode: StartupRecoveryMode;
|
|
36
|
-
/** Cursor found in history and used for this recovery pass. */
|
|
37
|
-
cursor: string | null;
|
|
38
|
-
/** Missed inbound messages (own messages excluded), oldest first. */
|
|
39
|
-
messages: TPage['messages'];
|
|
40
|
-
/** First page fetched — reusable as hydration context for recovered turns. */
|
|
41
|
-
newestPage: TPage;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Startup recovery runs before the stream starts. The first SSE connection
|
|
45
|
-
* therefore needs no second pass, while later reconnects still need catch-up.
|
|
46
|
-
*/
|
|
47
|
-
export declare function createReconnectRecoveryGate(): () => boolean;
|
|
48
|
-
export declare function collectMissedInboundMessages<TPage extends StartupRecoveryPage>(input: {
|
|
49
|
-
fetchPage: (before?: string) => Promise<TPage>;
|
|
50
|
-
cursor: string | null | undefined;
|
|
51
|
-
cursorCandidates?: readonly string[];
|
|
52
|
-
agentId: string;
|
|
53
|
-
maxMessages?: number;
|
|
54
|
-
}): Promise<StartupRecoveryResult<TPage>>;
|
package/dist/startup-recovery.js
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Startup recovery for inbound messages missed while the host was offline.
|
|
3
|
-
*
|
|
4
|
-
* The host persists a `lastInboundMessageId` cursor per conversation. On
|
|
5
|
-
* startup we paginate `getMessagesPage` (newest-first pages, older pages via
|
|
6
|
-
* its `before` message-id parameter) until the cursor is found or a hard
|
|
7
|
-
* per-conversation bound is hit, then replay everything after the cursor.
|
|
8
|
-
*
|
|
9
|
-
* Claude also accepts legacy cursor candidates so it can migrate old
|
|
10
|
-
* execution-mode-scoped state into one conversation-level recovery cursor.
|
|
11
|
-
*/
|
|
12
|
-
export const STARTUP_RECOVERY_PAGE_SIZE = 25;
|
|
13
|
-
export const STARTUP_RECOVERY_MAX_MESSAGES = 500;
|
|
14
|
-
/**
|
|
15
|
-
* Startup recovery runs before the stream starts. The first SSE connection
|
|
16
|
-
* therefore needs no second pass, while later reconnects still need catch-up.
|
|
17
|
-
*/
|
|
18
|
-
export function createReconnectRecoveryGate() {
|
|
19
|
-
let hasConnected = false;
|
|
20
|
-
return () => {
|
|
21
|
-
const shouldRecover = hasConnected;
|
|
22
|
-
hasConnected = true;
|
|
23
|
-
return shouldRecover;
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
export async function collectMissedInboundMessages(input) {
|
|
27
|
-
const maxMessages = input.maxMessages ?? STARTUP_RECOVERY_MAX_MESSAGES;
|
|
28
|
-
const newestPage = await input.fetchPage();
|
|
29
|
-
const collected = [...newestPage.messages];
|
|
30
|
-
const seenIds = new Set(collected.map((message) => message.id));
|
|
31
|
-
const candidateCursorIds = new Set(input.cursor
|
|
32
|
-
? [input.cursor]
|
|
33
|
-
: (input.cursorCandidates ?? []).filter(Boolean));
|
|
34
|
-
const foundCursorIds = new Set();
|
|
35
|
-
const recordFoundCursors = (messages) => {
|
|
36
|
-
for (const message of messages) {
|
|
37
|
-
if (candidateCursorIds.has(message.id))
|
|
38
|
-
foundCursorIds.add(message.id);
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
const cursorSearchComplete = () => foundCursorIds.size > 0;
|
|
42
|
-
const findNewestCursor = (messages) => messages.find((message) => candidateCursorIds.has(message.id))?.id ?? null;
|
|
43
|
-
recordFoundCursors(collected);
|
|
44
|
-
if (candidateCursorIds.size > 0) {
|
|
45
|
-
while (!cursorSearchComplete() && collected.length < maxMessages) {
|
|
46
|
-
// Pages are newest-first, so the last collected message is the oldest.
|
|
47
|
-
const before = collected[collected.length - 1]?.id;
|
|
48
|
-
if (!before)
|
|
49
|
-
break;
|
|
50
|
-
const page = await input.fetchPage(before);
|
|
51
|
-
const fresh = page.messages.filter((message) => !seenIds.has(message.id));
|
|
52
|
-
// No pagination progress (history exhausted, or the server ignored the
|
|
53
|
-
// `before` cursor because that message was hard-deleted) — stop here.
|
|
54
|
-
if (fresh.length === 0)
|
|
55
|
-
break;
|
|
56
|
-
for (const message of fresh)
|
|
57
|
-
seenIds.add(message.id);
|
|
58
|
-
collected.push(...fresh);
|
|
59
|
-
recordFoundCursors(fresh);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
const resolvedCursor = cursorSearchComplete()
|
|
63
|
-
? findNewestCursor(collected)
|
|
64
|
-
: null;
|
|
65
|
-
// Preserve the API's deterministic newest-first order. Timestamps are
|
|
66
|
-
// display data and may be equal or absent, so reversing wire order is the
|
|
67
|
-
// only lossless oldest-first replay sequence.
|
|
68
|
-
const ascending = [...collected].reverse();
|
|
69
|
-
const completedSourceMessageIds = new Set(collected.flatMap((message) => {
|
|
70
|
-
if (message.senderId !== input.agentId)
|
|
71
|
-
return [];
|
|
72
|
-
const metadata = message.metadata;
|
|
73
|
-
if (metadata?.turnSemantics !== 'turn_complete')
|
|
74
|
-
return [];
|
|
75
|
-
return typeof metadata.sourceMessageId === 'string'
|
|
76
|
-
? [metadata.sourceMessageId]
|
|
77
|
-
: [];
|
|
78
|
-
}));
|
|
79
|
-
const inboundOnly = (messages) => messages.filter((message) => message.senderId !== input.agentId && !completedSourceMessageIds.has(message.id));
|
|
80
|
-
let mode;
|
|
81
|
-
let missed;
|
|
82
|
-
if (resolvedCursor) {
|
|
83
|
-
const cursorIndex = ascending.findIndex((message) => message.id === resolvedCursor);
|
|
84
|
-
mode = 'after-cursor';
|
|
85
|
-
missed = inboundOnly(ascending.slice(cursorIndex + 1));
|
|
86
|
-
}
|
|
87
|
-
else if (candidateCursorIds.size > 0 && collected.length >= maxMessages) {
|
|
88
|
-
mode = 'truncated-window';
|
|
89
|
-
missed = inboundOnly(ascending.slice(-maxMessages));
|
|
90
|
-
}
|
|
91
|
-
else {
|
|
92
|
-
mode = 'latest-only';
|
|
93
|
-
missed = inboundOnly(ascending).slice(-1);
|
|
94
|
-
}
|
|
95
|
-
// Safe: `missed` only holds elements of pages returned by `fetchPage`.
|
|
96
|
-
return {
|
|
97
|
-
mode,
|
|
98
|
-
cursor: resolvedCursor,
|
|
99
|
-
messages: missed,
|
|
100
|
-
newestPage,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
export interface StreamingBufferOptions {
|
|
2
|
-
throttleMs: number;
|
|
3
|
-
getText: () => string;
|
|
4
|
-
setText: (text: string) => void;
|
|
5
|
-
getTimer: () => ReturnType<typeof setTimeout> | null;
|
|
6
|
-
setTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
|
|
7
|
-
onWrite: (text: string) => void;
|
|
8
|
-
schedule?: typeof setTimeout;
|
|
9
|
-
cancel?: typeof clearTimeout;
|
|
10
|
-
}
|
|
11
|
-
export declare function createStreamingBuffer(options: StreamingBufferOptions): {
|
|
12
|
-
append(delta: string): void;
|
|
13
|
-
flush: () => void;
|
|
14
|
-
clear(): void;
|
|
15
|
-
replace(text: string): void;
|
|
16
|
-
};
|
package/dist/streaming-buffer.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
export function createStreamingBuffer(options) {
|
|
2
|
-
const schedule = options.schedule ?? setTimeout;
|
|
3
|
-
const cancel = options.cancel ?? clearTimeout;
|
|
4
|
-
const flush = () => {
|
|
5
|
-
const timer = options.getTimer();
|
|
6
|
-
if (timer) {
|
|
7
|
-
cancel(timer);
|
|
8
|
-
options.setTimer(null);
|
|
9
|
-
}
|
|
10
|
-
const text = options.getText();
|
|
11
|
-
if (text) {
|
|
12
|
-
options.onWrite(text);
|
|
13
|
-
}
|
|
14
|
-
};
|
|
15
|
-
return {
|
|
16
|
-
append(delta) {
|
|
17
|
-
if (!delta)
|
|
18
|
-
return;
|
|
19
|
-
options.setText(`${options.getText()}${delta}`);
|
|
20
|
-
if (options.getTimer())
|
|
21
|
-
return;
|
|
22
|
-
const timer = schedule(() => {
|
|
23
|
-
options.setTimer(null);
|
|
24
|
-
const text = options.getText();
|
|
25
|
-
if (text) {
|
|
26
|
-
options.onWrite(text);
|
|
27
|
-
}
|
|
28
|
-
}, options.throttleMs);
|
|
29
|
-
options.setTimer(timer);
|
|
30
|
-
},
|
|
31
|
-
flush,
|
|
32
|
-
clear() {
|
|
33
|
-
const timer = options.getTimer();
|
|
34
|
-
if (timer) {
|
|
35
|
-
cancel(timer);
|
|
36
|
-
options.setTimer(null);
|
|
37
|
-
}
|
|
38
|
-
options.setText('');
|
|
39
|
-
},
|
|
40
|
-
replace(text) {
|
|
41
|
-
const timer = options.getTimer();
|
|
42
|
-
if (timer) {
|
|
43
|
-
cancel(timer);
|
|
44
|
-
options.setTimer(null);
|
|
45
|
-
}
|
|
46
|
-
options.setText(text);
|
|
47
|
-
if (text) {
|
|
48
|
-
options.onWrite(text);
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
}
|