@canonmsg/claude-code-plugin 0.10.0 → 0.12.1
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/README.md +24 -5
- package/dist/host-runtime.js +0 -2
- package/dist/host.js +416 -23
- package/dist/register.js +23 -19
- package/dist/server.js +58 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -15,13 +15,21 @@ canon-register --name "My Claude" --description "My Claude Code agent" --phone "
|
|
|
15
15
|
canon-claude
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.).
|
|
18
|
+
Send a message to your agent in the Canon app. Claude Code responds with full capabilities (file read/write, bash, etc.). Keep the `canon-claude` terminal open; closing it takes this local agent offline.
|
|
19
|
+
|
|
20
|
+
After a restart or closed terminal, use the shared local manager to list and revive all recorded local agents:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @canonmsg/local-agents
|
|
24
|
+
canon-necromance
|
|
25
|
+
canon-necromance revive frontend
|
|
26
|
+
```
|
|
19
27
|
|
|
20
28
|
## Features
|
|
21
29
|
|
|
22
30
|
- **Two-way messaging** — Messages from Canon flow to Claude Code and back
|
|
23
|
-
- **Session controls** —
|
|
24
|
-
- **Live
|
|
31
|
+
- **Session controls** — Canon renders setup and live controls from the runtime descriptor Claude publishes
|
|
32
|
+
- **Live preview** — See Claude's current live preview/status in the app
|
|
25
33
|
- **Interrupt** — Stop Claude mid-response from the app
|
|
26
34
|
- **Context meter** — See context window usage in the app
|
|
27
35
|
- **Max plan auth** — Uses your Claude subscription, no API key billing
|
|
@@ -35,10 +43,19 @@ canon-claude --cwd /path/to/project
|
|
|
35
43
|
Advertise multiple project choices to the Canon app:
|
|
36
44
|
|
|
37
45
|
```bash
|
|
38
|
-
canon-claude --cwd ~/dev --workspace ~/dev
|
|
46
|
+
canon-claude --cwd ~/dev --workspace-root ~/dev
|
|
39
47
|
```
|
|
40
48
|
|
|
41
|
-
`--cwd` is the default workspace. Each `--workspace` value
|
|
49
|
+
`--cwd` is the default workspace. Each `--workspace-root` value is an approved local root; the host discovers immediate child projects with common markers such as `.git`, `package.json`, `pyproject.toml`, `Cargo.toml`, or `go.mod` and publishes them as selectable projects during session creation. Use repeated `--workspace /path/to/project` entries to advertise specific projects outside those roots. Worktree mode creates a per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-project mode runs directly in the selected directory.
|
|
50
|
+
|
|
51
|
+
Current Canon truth for Claude host mode:
|
|
52
|
+
|
|
53
|
+
- model is live-editable
|
|
54
|
+
- thinking level / effort is live-editable when the host reports it
|
|
55
|
+
- workspace selection is setup-only
|
|
56
|
+
- execution mode selection is setup-only
|
|
57
|
+
- permission modes are rendered exactly as the Claude runtime reports them; Canon does not flatten them to a smaller hardcoded set
|
|
58
|
+
- 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
|
|
42
59
|
|
|
43
60
|
## Multiple agents
|
|
44
61
|
|
|
@@ -51,6 +68,8 @@ CANON_AGENT=frontend canon-claude --cwd ~/projects/frontend
|
|
|
51
68
|
|
|
52
69
|
For a lighter integration without session controls, run Canon as a channel plugin inside your own Claude Code session. See [full setup guide](../../docs/claude-code-setup.md#channel-mode-alternative).
|
|
53
70
|
|
|
71
|
+
Channel mode is embedded inside Claude Code and is shown as non-revivable by `canon-necromance`. Use `canon-claude` host mode when you want phone-controlled runtime sessions.
|
|
72
|
+
|
|
54
73
|
## Development
|
|
55
74
|
|
|
56
75
|
```bash
|
package/dist/host-runtime.js
CHANGED
|
@@ -69,8 +69,6 @@ export function renderCanonHostInboundContent(message, materialized) {
|
|
|
69
69
|
}
|
|
70
70
|
function describeContactCard(card) {
|
|
71
71
|
const parts = [`${card.userType} · userId: ${card.userId}`];
|
|
72
|
-
if (card.accessLevel)
|
|
73
|
-
parts.push(`access: ${card.accessLevel}`);
|
|
74
72
|
if (card.ownerName)
|
|
75
73
|
parts.push(`owner: ${card.ownerName}`);
|
|
76
74
|
if (card.about)
|
package/dist/host.js
CHANGED
|
@@ -26,18 +26,22 @@ import { execFileSync } from 'node:child_process';
|
|
|
26
26
|
import { existsSync } from 'node:fs';
|
|
27
27
|
import { query, } from '@anthropic-ai/claude-agent-sdk';
|
|
28
28
|
import { isAnthropicImageAttachment, materializeMessageMedia, toAnthropicImageBlock, } from '@canonmsg/agent-sdk';
|
|
29
|
-
import { CLAUDE_PERMISSION_MODE_OPTIONS,
|
|
29
|
+
import { CLAUDE_PERMISSION_MODE_OPTIONS, buildConfiguredWorkspaceOptionsWithRoots, buildConversationEnvironmentKey, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceRoots, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, HOST_ADMISSION_ACTIONS_DISABLED, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, buildLocalRuntimeId, getActiveProfileLock, heartbeatLocalRuntimeEntry, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, initRTDBAuth, rtdbRead, rtdbWrite, writeRuntimeInfo, writeSessionState, clearSessionState, writeTurnState, loadRuntimeSessionState, markLocalRuntimeStopped, releaseConversationEnvironment, saveRuntimeSessionState, clearRuntimeSessionState, upsertLocalRuntimeEntry, } from '@canonmsg/core';
|
|
30
30
|
import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, publishHostSessionSnapshots, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
|
|
31
31
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
32
32
|
import { createStreamingBuffer } from './streaming-buffer.js';
|
|
33
33
|
import { runCli } from './cli-entry.js';
|
|
34
34
|
let workingDir = process.cwd();
|
|
35
35
|
let workspaceOptions = [];
|
|
36
|
+
let workspaceRoots = [];
|
|
37
|
+
let workspaceRootMetadata = [];
|
|
36
38
|
let runtimeModels = [];
|
|
37
39
|
const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
|
|
38
40
|
let runtimeMetadata = {
|
|
39
41
|
account: null,
|
|
40
42
|
mcpServers: [],
|
|
43
|
+
commands: [],
|
|
44
|
+
agents: [],
|
|
41
45
|
plugins: [],
|
|
42
46
|
loadedAt: 0,
|
|
43
47
|
};
|
|
@@ -56,25 +60,32 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
56
60
|
},
|
|
57
61
|
{
|
|
58
62
|
id: 'workspace',
|
|
59
|
-
label: '
|
|
63
|
+
label: 'Project',
|
|
60
64
|
options: input.workspaces.map((workspace) => ({
|
|
61
65
|
value: workspace.id,
|
|
62
66
|
label: workspace.label,
|
|
67
|
+
...(workspace.description ? { description: workspace.description } : {}),
|
|
68
|
+
...(workspace.workspaceRootId ? { workspaceRootId: workspace.workspaceRootId } : {}),
|
|
69
|
+
...(workspace.workspaceRelativePath ? { workspaceRelativePath: workspace.workspaceRelativePath } : {}),
|
|
70
|
+
...(workspace.source ? { source: workspace.source } : {}),
|
|
63
71
|
})),
|
|
64
72
|
defaultValue: input.workspaces[0]?.id ?? null,
|
|
65
73
|
availability: 'setup',
|
|
66
74
|
liveBehavior: 'none',
|
|
67
75
|
selectionPolicy: 'inherit',
|
|
76
|
+
description: input.workspaceRoots?.length
|
|
77
|
+
? 'Choose one of the projects discovered inside the approved local roots for this host.'
|
|
78
|
+
: 'Choose one of the local projects advertised by this host.',
|
|
68
79
|
},
|
|
69
80
|
{
|
|
70
81
|
id: 'executionMode',
|
|
71
82
|
label: 'Execution mode',
|
|
72
83
|
options: input.executionModes.map((mode) => ({
|
|
73
84
|
value: mode,
|
|
74
|
-
label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared
|
|
85
|
+
label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared project',
|
|
75
86
|
description: mode === 'worktree'
|
|
76
|
-
? 'Creates or reuses a per-conversation git worktree under ~/.canon/conversation-worktrees when the selected
|
|
77
|
-
: 'Runs directly in the selected
|
|
87
|
+
? 'Creates or reuses a per-conversation git worktree under ~/.canon/conversation-worktrees when the selected project is a git repo.'
|
|
88
|
+
: 'Runs directly in the selected project folder. Changes happen there.',
|
|
78
89
|
})),
|
|
79
90
|
defaultValue: null,
|
|
80
91
|
availability: 'setup',
|
|
@@ -106,8 +117,97 @@ function buildClaudeRuntimeDescriptor(input) {
|
|
|
106
117
|
selectionPolicy: 'inherit',
|
|
107
118
|
},
|
|
108
119
|
],
|
|
120
|
+
actions: [
|
|
121
|
+
{
|
|
122
|
+
id: 'plan',
|
|
123
|
+
label: 'Plan first',
|
|
124
|
+
description: 'Switch Claude into Plan mode. Text after /plan is kept as a ready prompt while the mode applies.',
|
|
125
|
+
aliases: ['plan'],
|
|
126
|
+
category: 'plan',
|
|
127
|
+
placements: ['composer_slash', 'command_palette'],
|
|
128
|
+
availability: ['always'],
|
|
129
|
+
trailingTextBehavior: 'send_as_prompt',
|
|
130
|
+
dispatch: {
|
|
131
|
+
kind: 'control',
|
|
132
|
+
controlId: 'permissionMode',
|
|
133
|
+
value: 'plan',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'runtime-status',
|
|
138
|
+
label: 'Runtime status',
|
|
139
|
+
description: 'Open Claude runtime details, including account, project, and policy status.',
|
|
140
|
+
aliases: ['status'],
|
|
141
|
+
category: 'details',
|
|
142
|
+
placements: ['composer_slash', 'command_palette'],
|
|
143
|
+
availability: ['always'],
|
|
144
|
+
dispatch: { kind: 'open_details', target: 'status' },
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'mcp-inventory',
|
|
148
|
+
label: 'MCP servers',
|
|
149
|
+
description: 'Open the Claude MCP inventory published by the host.',
|
|
150
|
+
aliases: ['mcp'],
|
|
151
|
+
category: 'details',
|
|
152
|
+
placements: ['composer_slash', 'command_palette'],
|
|
153
|
+
availability: ['always'],
|
|
154
|
+
dispatch: { kind: 'open_details', target: 'mcp' },
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'plugin-inventory',
|
|
158
|
+
label: 'Plugins',
|
|
159
|
+
description: 'Open the Claude plugin and command inventory published by the host.',
|
|
160
|
+
aliases: ['plugins'],
|
|
161
|
+
category: 'details',
|
|
162
|
+
placements: ['composer_slash', 'command_palette'],
|
|
163
|
+
availability: ['always'],
|
|
164
|
+
dispatch: { kind: 'open_details', target: 'plugins' },
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: 'model-details',
|
|
168
|
+
label: 'Model control',
|
|
169
|
+
description: 'Open runtime details and use the live Model control.',
|
|
170
|
+
aliases: ['model'],
|
|
171
|
+
category: 'details',
|
|
172
|
+
placements: ['composer_slash', 'command_palette'],
|
|
173
|
+
availability: ['always'],
|
|
174
|
+
dispatch: { kind: 'open_details', target: 'model' },
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
id: 'permission-details',
|
|
178
|
+
label: 'Permission mode',
|
|
179
|
+
description: 'Open runtime details and use the live Permission mode control.',
|
|
180
|
+
aliases: ['permission'],
|
|
181
|
+
category: 'details',
|
|
182
|
+
placements: ['composer_slash', 'command_palette'],
|
|
183
|
+
availability: ['always'],
|
|
184
|
+
dispatch: { kind: 'open_details', target: 'permissionMode' },
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: 'effort-details',
|
|
188
|
+
label: 'Thinking level',
|
|
189
|
+
description: 'Open runtime details and use the live Thinking level control.',
|
|
190
|
+
aliases: ['effort'],
|
|
191
|
+
category: 'details',
|
|
192
|
+
placements: ['composer_slash', 'command_palette'],
|
|
193
|
+
availability: ['always'],
|
|
194
|
+
dispatch: { kind: 'open_details', target: 'effort' },
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
id: 'stop',
|
|
198
|
+
label: 'Stop',
|
|
199
|
+
description: 'Interrupt the current Claude turn.',
|
|
200
|
+
aliases: ['stop'],
|
|
201
|
+
category: 'turn',
|
|
202
|
+
placements: ['composer_slash', 'command_palette'],
|
|
203
|
+
availability: ['busy'],
|
|
204
|
+
dispatch: { kind: 'signal', signal: 'interrupt' },
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
workspaceRoots: input.workspaceRoots,
|
|
109
208
|
supportsInterrupt: true,
|
|
110
209
|
streamingTextMode: 'delta',
|
|
210
|
+
admissionActions: HOST_ADMISSION_ACTIONS_DISABLED,
|
|
111
211
|
};
|
|
112
212
|
}
|
|
113
213
|
function normalizeClaudeEffortLevel(value) {
|
|
@@ -172,11 +272,10 @@ async function loadSessionConfig(conversationId, agentId) {
|
|
|
172
272
|
effort: normalizeClaudeEffortLevel(config?.effort),
|
|
173
273
|
};
|
|
174
274
|
}
|
|
175
|
-
// Default to 'locked' (shared workspace) when no mode has been picked. The
|
|
176
|
-
// UI still lets owners flip to 'worktree'; this just stops sessions from
|
|
177
|
-
// failing closed when the mode has never been written.
|
|
178
275
|
function resolveSessionExecutionMode(config) {
|
|
179
|
-
|
|
276
|
+
if (config?.executionMode)
|
|
277
|
+
return config.executionMode;
|
|
278
|
+
throw new ExecutionEnvironmentError('Session config is missing an execution mode.', 'Choose Isolated worktree or Use shared project before starting this coding session.');
|
|
180
279
|
}
|
|
181
280
|
function resolveWorkspaceCwd(config) {
|
|
182
281
|
return resolveHostWorkspaceCwd({
|
|
@@ -245,6 +344,8 @@ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
|
|
|
245
344
|
return {
|
|
246
345
|
account: init.account ?? null,
|
|
247
346
|
mcpServers: pluginInfo?.mcpServers ?? mcpServers,
|
|
347
|
+
commands: pluginInfo?.commands ?? init.commands ?? [],
|
|
348
|
+
agents: pluginInfo?.agents ?? init.agents ?? [],
|
|
248
349
|
plugins: pluginInfo?.plugins ?? [],
|
|
249
350
|
loadedAt: Date.now(),
|
|
250
351
|
};
|
|
@@ -369,10 +470,32 @@ function buildClaudeRuntimeInfo(input) {
|
|
|
369
470
|
description: plugin.source ?? plugin.path,
|
|
370
471
|
})),
|
|
371
472
|
},
|
|
473
|
+
{
|
|
474
|
+
id: 'commands',
|
|
475
|
+
label: 'Commands',
|
|
476
|
+
entries: metadata.commands.map((command) => ({
|
|
477
|
+
id: command.name,
|
|
478
|
+
label: command.name.startsWith('/') ? command.name : `/${command.name}`,
|
|
479
|
+
status: 'configured',
|
|
480
|
+
description: command.description ?? command.argumentHint ?? undefined,
|
|
481
|
+
})),
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
id: 'agents',
|
|
485
|
+
label: 'Agents',
|
|
486
|
+
entries: metadata.agents.map((agent) => ({
|
|
487
|
+
id: agent.name,
|
|
488
|
+
label: agent.name,
|
|
489
|
+
status: 'configured',
|
|
490
|
+
description: agent.description ?? agent.model ?? undefined,
|
|
491
|
+
})),
|
|
492
|
+
},
|
|
372
493
|
],
|
|
373
494
|
execution: {
|
|
374
495
|
resolvedWorkspaceLabel: input.workspaceLabel,
|
|
375
496
|
resolvedCwd: input.resolvedCwd,
|
|
497
|
+
workspaceRootId: input.workspaceRootId ?? null,
|
|
498
|
+
workspaceRelativePath: input.workspaceRelativePath ?? null,
|
|
376
499
|
executionMode: input.executionMode,
|
|
377
500
|
executionBranch: input.executionBranch,
|
|
378
501
|
worktreePath: input.worktreePath,
|
|
@@ -495,6 +618,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
495
618
|
systemPrompt: { type: 'preset', preset: 'claude_code' },
|
|
496
619
|
tools: { type: 'preset', preset: 'claude_code' },
|
|
497
620
|
permissionMode: config.permissionMode,
|
|
621
|
+
allowDangerouslySkipPermissions: true,
|
|
498
622
|
cwd,
|
|
499
623
|
includePartialMessages: true,
|
|
500
624
|
maxTurns: 200,
|
|
@@ -534,7 +658,30 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
534
658
|
if (toolName === 'ExitPlanMode') {
|
|
535
659
|
try {
|
|
536
660
|
const planId = randomUUID();
|
|
537
|
-
const
|
|
661
|
+
const allowedPrompts = Array.isArray(input.allowedPrompts)
|
|
662
|
+
? input.allowedPrompts
|
|
663
|
+
.map((entry) => {
|
|
664
|
+
if (!entry || typeof entry !== 'object') {
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
const prompt = entry.prompt;
|
|
668
|
+
const tool = entry.tool;
|
|
669
|
+
if (typeof prompt !== 'string' || !prompt.trim()) {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
tool: typeof tool === 'string' && tool.trim() ? tool : 'Tool',
|
|
674
|
+
prompt,
|
|
675
|
+
};
|
|
676
|
+
})
|
|
677
|
+
.filter((entry) => Boolean(entry))
|
|
678
|
+
: [];
|
|
679
|
+
const planApproval = buildPlanApprovalRequest(planId, 'Plan ready for review.', allowedPrompts.length > 0 ? {
|
|
680
|
+
summary: 'Claude has prepared a plan and is asking permission to continue.',
|
|
681
|
+
allowedPrompts,
|
|
682
|
+
} : {
|
|
683
|
+
summary: 'Claude has prepared a plan and is asking permission to continue.',
|
|
684
|
+
});
|
|
538
685
|
await client.sendMessage(conversationId, planApproval.text, {
|
|
539
686
|
metadata: {
|
|
540
687
|
...planApproval.metadata,
|
|
@@ -594,6 +741,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
594
741
|
currentTurnOpenedAt: null,
|
|
595
742
|
lastAcceptedIntent: null,
|
|
596
743
|
lastActivity: Date.now(),
|
|
744
|
+
typingKeepaliveTimer: null,
|
|
597
745
|
closed: false,
|
|
598
746
|
sdkSessionId: resumeSessionId || null,
|
|
599
747
|
pendingReply: null,
|
|
@@ -650,6 +798,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
650
798
|
session.pendingFinalDeliveryText = null;
|
|
651
799
|
session.toolInProgress = false;
|
|
652
800
|
session.turnState = 'idle';
|
|
801
|
+
stopVisibleWorkSignal();
|
|
653
802
|
writeTurn();
|
|
654
803
|
drainPendingInput();
|
|
655
804
|
}
|
|
@@ -668,6 +817,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
668
817
|
session.pendingFinalDeliveryText = finalText;
|
|
669
818
|
session.state.state = 'running';
|
|
670
819
|
session.turnState = 'streaming';
|
|
820
|
+
stopVisibleWorkSignal();
|
|
671
821
|
streamingBuffer.replace(finalText);
|
|
672
822
|
writeState();
|
|
673
823
|
writeTurn();
|
|
@@ -719,6 +869,9 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
719
869
|
executionMode: session.environment.mode,
|
|
720
870
|
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
721
871
|
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
872
|
+
...(resolveExecutionFallbackReason(session.environment)
|
|
873
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
874
|
+
: {}),
|
|
722
875
|
hostMode: true,
|
|
723
876
|
isActive: true,
|
|
724
877
|
}).catch(() => { });
|
|
@@ -737,6 +890,25 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
737
890
|
: {}),
|
|
738
891
|
}).catch(() => { });
|
|
739
892
|
}
|
|
893
|
+
function refreshVisibleWorkSignal() {
|
|
894
|
+
if (session.closed)
|
|
895
|
+
return;
|
|
896
|
+
if (session.turnState !== 'thinking' && session.turnState !== 'tool')
|
|
897
|
+
return;
|
|
898
|
+
client.setTyping(conversationId, true, 'thinking').catch(() => { });
|
|
899
|
+
}
|
|
900
|
+
function startVisibleWorkSignal() {
|
|
901
|
+
refreshVisibleWorkSignal();
|
|
902
|
+
if (session.typingKeepaliveTimer)
|
|
903
|
+
return;
|
|
904
|
+
session.typingKeepaliveTimer = setInterval(refreshVisibleWorkSignal, 3500);
|
|
905
|
+
}
|
|
906
|
+
function stopVisibleWorkSignal() {
|
|
907
|
+
if (session.typingKeepaliveTimer) {
|
|
908
|
+
clearInterval(session.typingKeepaliveTimer);
|
|
909
|
+
session.typingKeepaliveTimer = null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
740
912
|
async function markQueuedMessageAccepted(sourceMessageId, markAccepted) {
|
|
741
913
|
if (!markAccepted || !sourceMessageId)
|
|
742
914
|
return;
|
|
@@ -768,6 +940,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
768
940
|
if (session.state.state === 'running' && intent === 'interrupt') {
|
|
769
941
|
session.pendingInputs.unshift({ msg, intent, sourceMessageId, markAccepted });
|
|
770
942
|
session.turnState = 'interrupted';
|
|
943
|
+
stopVisibleWorkSignal();
|
|
771
944
|
writeTurn();
|
|
772
945
|
void session.query.interrupt().catch(() => { });
|
|
773
946
|
return;
|
|
@@ -808,18 +981,22 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
808
981
|
if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
|
|
809
982
|
session.toolInProgress = true;
|
|
810
983
|
session.turnState = 'tool';
|
|
984
|
+
startVisibleWorkSignal();
|
|
811
985
|
writeTurn();
|
|
812
986
|
}
|
|
813
987
|
else if (event?.type === 'content_block_stop' && session.toolInProgress) {
|
|
814
988
|
session.toolInProgress = false;
|
|
815
989
|
if (session.state.state === 'running') {
|
|
816
990
|
session.turnState = 'thinking';
|
|
991
|
+
startVisibleWorkSignal();
|
|
817
992
|
writeTurn();
|
|
818
993
|
}
|
|
819
994
|
}
|
|
820
995
|
else if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
821
996
|
session.toolInProgress = false;
|
|
822
997
|
session.turnState = 'streaming';
|
|
998
|
+
stopVisibleWorkSignal();
|
|
999
|
+
client.setTyping(conversationId, false).catch(() => { });
|
|
823
1000
|
writeTurn();
|
|
824
1001
|
onStreamDelta(event.delta.text);
|
|
825
1002
|
}
|
|
@@ -840,9 +1017,10 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
840
1017
|
session.pendingFinalText = null;
|
|
841
1018
|
session.toolInProgress = false;
|
|
842
1019
|
session.turnState = 'thinking';
|
|
843
|
-
|
|
1020
|
+
startVisibleWorkSignal();
|
|
844
1021
|
}
|
|
845
1022
|
else if (state === 'idle') {
|
|
1023
|
+
stopVisibleWorkSignal();
|
|
846
1024
|
client.setTyping(conversationId, false).catch(() => { });
|
|
847
1025
|
}
|
|
848
1026
|
else if (state === 'requires_action') {
|
|
@@ -853,6 +1031,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
853
1031
|
}
|
|
854
1032
|
session.toolInProgress = false;
|
|
855
1033
|
session.turnState = 'waiting_input';
|
|
1034
|
+
stopVisibleWorkSignal();
|
|
856
1035
|
client.setTyping(conversationId, false).catch(() => { });
|
|
857
1036
|
}
|
|
858
1037
|
writeState();
|
|
@@ -952,6 +1131,12 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
952
1131
|
availableModels: modelList,
|
|
953
1132
|
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
954
1133
|
availableExecutionModes: config.availableExecutionModes,
|
|
1134
|
+
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1135
|
+
models: modelList,
|
|
1136
|
+
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1137
|
+
workspaceRoots: workspaceRootMetadata,
|
|
1138
|
+
executionModes: config.availableExecutionModes,
|
|
1139
|
+
}),
|
|
955
1140
|
});
|
|
956
1141
|
writeSessionState(conversationId, agentId, {
|
|
957
1142
|
...session.state,
|
|
@@ -960,6 +1145,9 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
960
1145
|
executionMode: session.environment.mode,
|
|
961
1146
|
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
962
1147
|
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
1148
|
+
...(resolveExecutionFallbackReason(session.environment)
|
|
1149
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
1150
|
+
: {}),
|
|
963
1151
|
hostMode: true,
|
|
964
1152
|
isActive: true,
|
|
965
1153
|
}).catch(() => { });
|
|
@@ -971,7 +1159,13 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
|
|
|
971
1159
|
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Session ${resumeSessionId ? 'resumed' : 'started'} `
|
|
972
1160
|
+ `(mode: ${environment.mode}, cwd: ${cwd})`);
|
|
973
1161
|
};
|
|
974
|
-
init().catch((err) =>
|
|
1162
|
+
init().catch((err) => {
|
|
1163
|
+
console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, err);
|
|
1164
|
+
if (resumeSessionId) {
|
|
1165
|
+
session.sdkSessionId = null;
|
|
1166
|
+
onSessionEnd(conversationId, null);
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
975
1169
|
return session;
|
|
976
1170
|
}
|
|
977
1171
|
// ── Main ────────────────────────────────────────────────────────────
|
|
@@ -981,16 +1175,28 @@ export async function main() {
|
|
|
981
1175
|
options: {
|
|
982
1176
|
cwd: { type: 'string' },
|
|
983
1177
|
workspace: { type: 'string', multiple: true },
|
|
1178
|
+
'workspace-root': { type: 'string', multiple: true },
|
|
984
1179
|
},
|
|
985
1180
|
strict: false,
|
|
986
1181
|
});
|
|
987
1182
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
988
1183
|
const configuredWorkspaces = (args.workspace ?? []).filter((value) => typeof value === 'string');
|
|
989
|
-
|
|
990
|
-
const
|
|
1184
|
+
const configuredWorkspaceRoots = (args['workspace-root'] ?? []).filter((value) => typeof value === 'string');
|
|
1185
|
+
const workspaceDiscovery = buildConfiguredWorkspaceOptionsWithRoots({
|
|
1186
|
+
primaryCwd: workingDir,
|
|
1187
|
+
configuredWorkspaces,
|
|
1188
|
+
workspaceRoots: configuredWorkspaceRoots,
|
|
1189
|
+
});
|
|
1190
|
+
workspaceOptions = workspaceDiscovery.workspaceOptions;
|
|
1191
|
+
workspaceRoots = workspaceDiscovery.workspaceRoots;
|
|
1192
|
+
workspaceRootMetadata = buildPublicWorkspaceRoots(workspaceRoots);
|
|
1193
|
+
for (const warning of workspaceDiscovery.warnings) {
|
|
1194
|
+
console.error(`[canon-host] ${warning}`);
|
|
1195
|
+
}
|
|
1196
|
+
const { apiKey, agentId: profileAgentId, agentName: profileAgentName, profile, baseUrl, lockHandle, } = resolveCanonAgent({ logPrefix: '[canon-host]', expectedClientType: 'claude-code' });
|
|
991
1197
|
console.error(`[canon-host] Starting${profile ? ` (profile: ${profile})` : ''} in ${workingDir}`);
|
|
992
1198
|
// ── Canon API client + RTDB auth ──
|
|
993
|
-
const client = new CanonClient(apiKey);
|
|
1199
|
+
const client = new CanonClient(apiKey, baseUrl);
|
|
994
1200
|
initRTDBAuth(client);
|
|
995
1201
|
let agentId;
|
|
996
1202
|
let ownerId = null;
|
|
@@ -1010,6 +1216,34 @@ export async function main() {
|
|
|
1010
1216
|
}
|
|
1011
1217
|
console.error(`[canon-host] Authenticated as ${agentId}`);
|
|
1012
1218
|
}
|
|
1219
|
+
const launchArgs = [...process.argv.slice(2)];
|
|
1220
|
+
if (!launchArgs.some((arg) => arg === '--cwd' || arg.startsWith('--cwd='))) {
|
|
1221
|
+
launchArgs.push('--cwd', workingDir);
|
|
1222
|
+
}
|
|
1223
|
+
const runtimeId = buildLocalRuntimeId({
|
|
1224
|
+
runtime: 'claude-code',
|
|
1225
|
+
profile,
|
|
1226
|
+
cwd: workingDir,
|
|
1227
|
+
launchCommand: ['canon-claude', ...launchArgs],
|
|
1228
|
+
});
|
|
1229
|
+
upsertLocalRuntimeEntry({
|
|
1230
|
+
id: runtimeId,
|
|
1231
|
+
runtime: 'claude-code',
|
|
1232
|
+
profile,
|
|
1233
|
+
agentId,
|
|
1234
|
+
agentName: profileAgentName,
|
|
1235
|
+
cwd: workingDir,
|
|
1236
|
+
baseCwd: workingDir,
|
|
1237
|
+
workspaceRoots: workspaceRoots.map((root) => root.cwd),
|
|
1238
|
+
workspaces: workspaceOptions.map((workspace) => workspace.cwd),
|
|
1239
|
+
launchCommand: ['canon-claude', ...launchArgs],
|
|
1240
|
+
pid: process.pid,
|
|
1241
|
+
status: profile ? 'running' : 'manual',
|
|
1242
|
+
reviveCapability: profile ? 'revivable' : 'manual',
|
|
1243
|
+
surfaceMode: 'host',
|
|
1244
|
+
lastStartedAt: new Date().toISOString(),
|
|
1245
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
1246
|
+
});
|
|
1013
1247
|
let streamConnected = false;
|
|
1014
1248
|
const hostAvailableExecutionModes = [
|
|
1015
1249
|
...EXECUTION_ENVIRONMENT_MODES,
|
|
@@ -1023,6 +1257,7 @@ export async function main() {
|
|
|
1023
1257
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1024
1258
|
models: runtimeModels,
|
|
1025
1259
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1260
|
+
workspaceRoots: workspaceRootMetadata,
|
|
1026
1261
|
executionModes: hostAvailableExecutionModes,
|
|
1027
1262
|
}),
|
|
1028
1263
|
};
|
|
@@ -1030,6 +1265,12 @@ export async function main() {
|
|
|
1030
1265
|
const knownConversationIds = new Set();
|
|
1031
1266
|
let lastKnownConversationRefreshAt = 0;
|
|
1032
1267
|
const publishRuntimeHeartbeat = async () => {
|
|
1268
|
+
heartbeatLocalRuntimeEntry(runtimeId, {
|
|
1269
|
+
agentId,
|
|
1270
|
+
agentName: profileAgentName,
|
|
1271
|
+
cwd: workingDir,
|
|
1272
|
+
baseCwd: workingDir,
|
|
1273
|
+
});
|
|
1033
1274
|
if (!streamConnected)
|
|
1034
1275
|
return;
|
|
1035
1276
|
await refreshKnownConversationIds().catch((error) => {
|
|
@@ -1080,11 +1321,14 @@ export async function main() {
|
|
|
1080
1321
|
descriptor: runtimeDescriptor.runtimeDescriptor ?? buildClaudeRuntimeDescriptor({
|
|
1081
1322
|
models: runtimeModels,
|
|
1082
1323
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1324
|
+
workspaceRoots: workspaceRootMetadata,
|
|
1083
1325
|
executionModes: hostAvailableExecutionModes,
|
|
1084
1326
|
}),
|
|
1085
1327
|
metadata,
|
|
1086
1328
|
workspaceLabel: workspace?.label ?? workspaceId ?? null,
|
|
1087
1329
|
resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
|
|
1330
|
+
workspaceRootId: workspace?.workspaceRootId ?? null,
|
|
1331
|
+
workspaceRelativePath: workspace?.workspaceRelativePath ?? null,
|
|
1088
1332
|
executionMode: session?.environment.mode ?? null,
|
|
1089
1333
|
executionBranch: session?.environment.branch ?? null,
|
|
1090
1334
|
worktreePath: session?.environment.worktreePath ?? null,
|
|
@@ -1109,6 +1353,7 @@ export async function main() {
|
|
|
1109
1353
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1110
1354
|
models: runtimeModels,
|
|
1111
1355
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1356
|
+
workspaceRoots: workspaceRootMetadata,
|
|
1112
1357
|
executionModes: hostAvailableExecutionModes,
|
|
1113
1358
|
}),
|
|
1114
1359
|
};
|
|
@@ -1123,6 +1368,7 @@ export async function main() {
|
|
|
1123
1368
|
runtimeDescriptor: buildClaudeRuntimeDescriptor({
|
|
1124
1369
|
models: runtimeModels,
|
|
1125
1370
|
workspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
1371
|
+
workspaceRoots: workspaceRootMetadata,
|
|
1126
1372
|
executionModes: hostAvailableExecutionModes,
|
|
1127
1373
|
}),
|
|
1128
1374
|
};
|
|
@@ -1182,10 +1428,20 @@ export async function main() {
|
|
|
1182
1428
|
if (!session)
|
|
1183
1429
|
return;
|
|
1184
1430
|
session.closed = true;
|
|
1431
|
+
if (session.typingKeepaliveTimer) {
|
|
1432
|
+
clearInterval(session.typingKeepaliveTimer);
|
|
1433
|
+
session.typingKeepaliveTimer = null;
|
|
1434
|
+
}
|
|
1185
1435
|
releaseConversationEnvironment(session.environment);
|
|
1186
1436
|
// Save SDK session ID for future resume
|
|
1187
1437
|
if (session.sdkSessionId) {
|
|
1188
1438
|
savedSessionIds.set(buildConversationEnvironmentKey(conversationId, session.environment.baseCwd), session.sdkSessionId);
|
|
1439
|
+
saveRuntimeSessionState(runtimeId, {
|
|
1440
|
+
conversationId,
|
|
1441
|
+
baseCwd: session.environment.baseCwd,
|
|
1442
|
+
executionMode: session.environment.mode,
|
|
1443
|
+
claudeSessionId: session.sdkSessionId,
|
|
1444
|
+
});
|
|
1189
1445
|
}
|
|
1190
1446
|
if (session.streamingTimer)
|
|
1191
1447
|
clearTimeout(session.streamingTimer);
|
|
@@ -1248,7 +1504,12 @@ export async function main() {
|
|
|
1248
1504
|
});
|
|
1249
1505
|
try {
|
|
1250
1506
|
const resumeKey = buildConversationEnvironmentKey(conversationId, environment.baseCwd);
|
|
1251
|
-
const
|
|
1507
|
+
const persisted = loadRuntimeSessionState(runtimeId, {
|
|
1508
|
+
conversationId,
|
|
1509
|
+
baseCwd: environment.baseCwd,
|
|
1510
|
+
executionMode: environment.mode,
|
|
1511
|
+
});
|
|
1512
|
+
const resumeId = savedSessionIds.get(resumeKey) ?? persisted?.claudeSessionId;
|
|
1252
1513
|
const session = createSession(conversationId, environment, agentId, client, (convoId, sdkSessionId) => {
|
|
1253
1514
|
// Called when session processing loop ends — save session ID for resume
|
|
1254
1515
|
const existing = sessions.get(convoId);
|
|
@@ -1256,6 +1517,19 @@ export async function main() {
|
|
|
1256
1517
|
releaseConversationEnvironment(existing.environment);
|
|
1257
1518
|
if (sdkSessionId) {
|
|
1258
1519
|
savedSessionIds.set(buildConversationEnvironmentKey(convoId, existing.environment.baseCwd), sdkSessionId);
|
|
1520
|
+
saveRuntimeSessionState(runtimeId, {
|
|
1521
|
+
conversationId: convoId,
|
|
1522
|
+
baseCwd: existing.environment.baseCwd,
|
|
1523
|
+
executionMode: existing.environment.mode,
|
|
1524
|
+
claudeSessionId: sdkSessionId,
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
else {
|
|
1528
|
+
clearRuntimeSessionState(runtimeId, {
|
|
1529
|
+
conversationId: convoId,
|
|
1530
|
+
baseCwd: existing.environment.baseCwd,
|
|
1531
|
+
executionMode: existing.environment.mode,
|
|
1532
|
+
});
|
|
1259
1533
|
}
|
|
1260
1534
|
}
|
|
1261
1535
|
sessions.delete(convoId);
|
|
@@ -1287,6 +1561,112 @@ export async function main() {
|
|
|
1287
1561
|
pendingSessionCreations.delete(conversationId);
|
|
1288
1562
|
}
|
|
1289
1563
|
}
|
|
1564
|
+
async function enqueueRecoveredInboundMessage(input) {
|
|
1565
|
+
const m = input.message;
|
|
1566
|
+
if (m.senderId === agentId)
|
|
1567
|
+
return;
|
|
1568
|
+
let materialized = [];
|
|
1569
|
+
if (m.id) {
|
|
1570
|
+
try {
|
|
1571
|
+
materialized = await materializeMessageMedia({
|
|
1572
|
+
id: m.id,
|
|
1573
|
+
attachments: m.attachments ?? [],
|
|
1574
|
+
}, { agentId, conversationId: input.conversationId });
|
|
1575
|
+
}
|
|
1576
|
+
catch (error) {
|
|
1577
|
+
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to materialize recovered media:`, error instanceof Error ? error.message : error);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
const sender = m.senderName || m.senderId;
|
|
1581
|
+
const isOwner = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
|
|
1582
|
+
const content = renderInboundContent(m, materialized);
|
|
1583
|
+
const hydrated = await loadHydratedInboundContext({
|
|
1584
|
+
conversationId: input.conversationId,
|
|
1585
|
+
message: m,
|
|
1586
|
+
senderName: sender,
|
|
1587
|
+
isOwner,
|
|
1588
|
+
});
|
|
1589
|
+
const behavior = input.hydratedPage?.behavior ?? hydrated.behavior;
|
|
1590
|
+
const workSessions = input.hydratedPage?.workSessions ?? hydrated.workSessions;
|
|
1591
|
+
const participantContext = hydrated.participantContext;
|
|
1592
|
+
const autoReply = decideAutoReply(participantContext, behavior);
|
|
1593
|
+
if (!autoReply.allow) {
|
|
1594
|
+
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Suppressed recovered auto-reply: ${autoReply.reason}`);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
let session;
|
|
1598
|
+
try {
|
|
1599
|
+
session = await getOrCreateSession(input.conversationId);
|
|
1600
|
+
}
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1603
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
1604
|
+
console.error(`[canon-host] [${input.conversationId.slice(0, 8)}] Failed to create recovered session: ${message}`);
|
|
1605
|
+
await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
|
|
1606
|
+
metadata: {
|
|
1607
|
+
turnSemantics: 'turn_complete',
|
|
1608
|
+
turnComplete: true,
|
|
1609
|
+
},
|
|
1610
|
+
}).catch(() => { });
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
1614
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
1615
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
1616
|
+
const promptText = buildCanonPrompt({
|
|
1617
|
+
content,
|
|
1618
|
+
conversationId: input.conversationId,
|
|
1619
|
+
participantContext,
|
|
1620
|
+
behavior,
|
|
1621
|
+
workSession: m.workSession,
|
|
1622
|
+
workSessions,
|
|
1623
|
+
});
|
|
1624
|
+
const messageContent = await buildCanonUserContent({ promptText, materialized });
|
|
1625
|
+
session.enqueueInbound({
|
|
1626
|
+
type: 'user',
|
|
1627
|
+
message: {
|
|
1628
|
+
role: 'user',
|
|
1629
|
+
content: messageContent,
|
|
1630
|
+
},
|
|
1631
|
+
parent_tool_use_id: null,
|
|
1632
|
+
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
1633
|
+
if (m.id) {
|
|
1634
|
+
saveRuntimeSessionState(runtimeId, {
|
|
1635
|
+
conversationId: input.conversationId,
|
|
1636
|
+
baseCwd: workingDir,
|
|
1637
|
+
lastInboundMessageId: m.id,
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
try {
|
|
1642
|
+
for (const conversation of conversationCache.values()) {
|
|
1643
|
+
const cursor = loadRuntimeSessionState(runtimeId, {
|
|
1644
|
+
conversationId: conversation.id,
|
|
1645
|
+
baseCwd: workingDir,
|
|
1646
|
+
})?.lastInboundMessageId;
|
|
1647
|
+
const page = await client.getMessagesPage(conversation.id, 25);
|
|
1648
|
+
const inboundMessages = page.messages
|
|
1649
|
+
.filter((message) => message.senderId !== agentId)
|
|
1650
|
+
.sort((a, b) => String(a.createdAt ?? '').localeCompare(String(b.createdAt ?? '')));
|
|
1651
|
+
const cursorIndex = cursor
|
|
1652
|
+
? inboundMessages.findIndex((message) => message.id === cursor)
|
|
1653
|
+
: -1;
|
|
1654
|
+
const messagesToRecover = cursorIndex >= 0
|
|
1655
|
+
? inboundMessages.slice(cursorIndex + 1)
|
|
1656
|
+
: inboundMessages.slice(-1);
|
|
1657
|
+
for (const message of messagesToRecover) {
|
|
1658
|
+
console.error(`[canon-host] [${conversation.id.slice(0, 8)}] Recovering inbound message on startup`);
|
|
1659
|
+
await enqueueRecoveredInboundMessage({
|
|
1660
|
+
conversationId: conversation.id,
|
|
1661
|
+
message: message,
|
|
1662
|
+
hydratedPage: page,
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
catch (error) {
|
|
1668
|
+
console.error('[canon-host] Failed startup recovery:', error instanceof Error ? error.message : error);
|
|
1669
|
+
}
|
|
1290
1670
|
// ── Connect SSE stream for inbound Canon messages ──
|
|
1291
1671
|
const stream = new CanonStream({
|
|
1292
1672
|
apiKey,
|
|
@@ -1370,7 +1750,12 @@ export async function main() {
|
|
|
1370
1750
|
const message = error instanceof Error ? error.message : String(error);
|
|
1371
1751
|
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
1372
1752
|
console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
1373
|
-
await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}
|
|
1753
|
+
await client.sendMessage(convoId, `I couldn't start a coding session for this workspace: ${userMessage}`, {
|
|
1754
|
+
metadata: {
|
|
1755
|
+
turnSemantics: 'turn_complete',
|
|
1756
|
+
turnComplete: true,
|
|
1757
|
+
},
|
|
1758
|
+
}).catch(() => { });
|
|
1374
1759
|
return;
|
|
1375
1760
|
}
|
|
1376
1761
|
const turnMetadata = normalizeTurnMetadata(m.metadata);
|
|
@@ -1393,6 +1778,13 @@ export async function main() {
|
|
|
1393
1778
|
},
|
|
1394
1779
|
parent_tool_use_id: null,
|
|
1395
1780
|
}, deliveryIntent, m.id ?? null, shouldMarkAccepted);
|
|
1781
|
+
if (m.id) {
|
|
1782
|
+
saveRuntimeSessionState(runtimeId, {
|
|
1783
|
+
conversationId: convoId,
|
|
1784
|
+
baseCwd: workingDir,
|
|
1785
|
+
lastInboundMessageId: m.id,
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1396
1788
|
})();
|
|
1397
1789
|
},
|
|
1398
1790
|
onConnected: () => {
|
|
@@ -1467,6 +1859,9 @@ export async function main() {
|
|
|
1467
1859
|
executionMode: session.environment.mode,
|
|
1468
1860
|
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
1469
1861
|
...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
|
|
1862
|
+
...(resolveExecutionFallbackReason(session.environment)
|
|
1863
|
+
? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
|
|
1864
|
+
: {}),
|
|
1470
1865
|
hostMode: true,
|
|
1471
1866
|
isActive: true,
|
|
1472
1867
|
}).catch(() => { });
|
|
@@ -1571,13 +1966,13 @@ export async function main() {
|
|
|
1571
1966
|
for (const convoId of [...sessions.keys()]) {
|
|
1572
1967
|
closeSession(convoId);
|
|
1573
1968
|
}
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
releaseLock(ap);
|
|
1969
|
+
markLocalRuntimeStopped(runtimeId);
|
|
1970
|
+
(lockHandle ?? getActiveProfileLock())?.release();
|
|
1577
1971
|
process.exit(0);
|
|
1578
1972
|
};
|
|
1579
1973
|
process.on('SIGINT', shutdown);
|
|
1580
1974
|
process.on('SIGTERM', shutdown);
|
|
1975
|
+
process.on('SIGHUP', shutdown);
|
|
1581
1976
|
console.error('[canon-host] Ready — sessions created on demand');
|
|
1582
1977
|
// Keep alive
|
|
1583
1978
|
await new Promise(() => { });
|
|
@@ -1585,8 +1980,6 @@ export async function main() {
|
|
|
1585
1980
|
// ── Entry ───────────────────────────────────────────────────────────
|
|
1586
1981
|
runCli(import.meta.url, main, (err) => {
|
|
1587
1982
|
console.error('[canon-host] Fatal error:', err);
|
|
1588
|
-
|
|
1589
|
-
if (ap)
|
|
1590
|
-
releaseLock(ap);
|
|
1983
|
+
getActiveProfileLock()?.release();
|
|
1591
1984
|
process.exit(1);
|
|
1592
1985
|
});
|
package/dist/register.js
CHANGED
|
@@ -9,14 +9,10 @@ import { setDefaultResultOrder } from 'node:dns';
|
|
|
9
9
|
*
|
|
10
10
|
* On approval, saves the agent to ~/.canon/agents.json under the profile name.
|
|
11
11
|
*/
|
|
12
|
-
import { registerAndWaitForApproval } from '@canonmsg/core';
|
|
13
|
-
import { readFileSync
|
|
14
|
-
import { join } from 'node:path';
|
|
15
|
-
import { homedir } from 'node:os';
|
|
12
|
+
import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRegistration, registerAndWaitForApproval, updatePendingRegistration, upsertAgentProfile, AGENTS_PATH, } from '@canonmsg/core';
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
16
14
|
import { parseArgs } from 'node:util';
|
|
17
15
|
import { runCli } from './cli-entry.js';
|
|
18
|
-
const CANON_DIR = join(homedir(), '.canon');
|
|
19
|
-
const AGENTS_PATH = join(CANON_DIR, 'agents.json');
|
|
20
16
|
export async function main() {
|
|
21
17
|
setDefaultResultOrder('ipv4first');
|
|
22
18
|
const { values } = parseArgs({
|
|
@@ -44,6 +40,7 @@ export async function main() {
|
|
|
44
40
|
// File doesn't exist yet
|
|
45
41
|
}
|
|
46
42
|
console.log(`Registering agent "${values.name}" (profile: ${profileName})...`);
|
|
43
|
+
const pending = getOrCreatePendingRegistration(profileName, 'claude-code');
|
|
47
44
|
const result = await registerAndWaitForApproval({
|
|
48
45
|
name: values.name,
|
|
49
46
|
description: values.description,
|
|
@@ -52,8 +49,14 @@ export async function main() {
|
|
|
52
49
|
clientType: 'claude-code',
|
|
53
50
|
baseUrl: values['base-url'],
|
|
54
51
|
requestedAgentId: existingAgentId,
|
|
52
|
+
localRegistrationId: pending.localRegistrationId,
|
|
55
53
|
}, {
|
|
56
|
-
onSubmitted: (requestId) => {
|
|
54
|
+
onSubmitted: (requestId, pollToken) => {
|
|
55
|
+
updatePendingRegistration(profileName, {
|
|
56
|
+
requestId,
|
|
57
|
+
pollToken,
|
|
58
|
+
clientType: 'claude-code',
|
|
59
|
+
});
|
|
57
60
|
console.log(`Registration submitted (request ID: ${requestId}).`);
|
|
58
61
|
console.log('Waiting for approval in Canon app...');
|
|
59
62
|
},
|
|
@@ -64,25 +67,26 @@ export async function main() {
|
|
|
64
67
|
console.log(''); // newline after dots
|
|
65
68
|
switch (result.status) {
|
|
66
69
|
case 'approved': {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
try {
|
|
71
|
-
profiles = JSON.parse(readFileSync(AGENTS_PATH, 'utf-8'));
|
|
70
|
+
if (!result.apiKey || !result.agentId || !result.agentName) {
|
|
71
|
+
console.error('Approval completed but Canon did not return a usable API key. Run this command again to resume key pickup.');
|
|
72
|
+
process.exit(1);
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
-
// File doesn't exist yet
|
|
75
|
-
}
|
|
76
|
-
profiles[profileName] = {
|
|
74
|
+
upsertAgentProfile(profileName, {
|
|
77
75
|
apiKey: result.apiKey,
|
|
78
76
|
agentId: result.agentId,
|
|
79
77
|
agentName: result.agentName,
|
|
80
78
|
registeredAt: new Date().toISOString(),
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
clientType: 'claude-code',
|
|
80
|
+
...(typeof values['base-url'] === 'string' ? { baseUrl: values['base-url'] } : {}),
|
|
81
|
+
});
|
|
82
|
+
if (result.requestId) {
|
|
83
|
+
await ackRegistrationApproval(values['base-url'], result.requestId, result.pollToken);
|
|
84
|
+
}
|
|
85
|
+
clearPendingRegistration(profileName);
|
|
83
86
|
console.log(`Approved! Agent: ${result.agentName} (${result.agentId})`);
|
|
84
87
|
console.log(`Saved as profile "${profileName}" in ~/.canon/agents.json`);
|
|
85
|
-
console.log('
|
|
88
|
+
console.log('Start it with: CANON_AGENT=' + profileName + ' canon-claude --cwd /path/to/project');
|
|
89
|
+
console.log('Keep that terminal open. Closing it takes this local agent offline.');
|
|
86
90
|
break;
|
|
87
91
|
}
|
|
88
92
|
case 'rejected':
|
package/dist/server.js
CHANGED
|
@@ -10,7 +10,7 @@ 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, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, writeSessionState, clearSessionState, } from '@canonmsg/core';
|
|
13
|
+
import { CanonClient, CanonStream, ApprovalManager, buildLocalRuntimeId, getActiveProfileLock, markLocalRuntimeStopped, loadProfiles, isProfileLocked, resolveCanonAgent, getActiveProfile, releaseLock, upsertLocalRuntimeEntry, initRTDBAuth, writeSessionState, clearSessionState, } from '@canonmsg/core';
|
|
14
14
|
import { ApprovalHttpServer } from './approval-server.js';
|
|
15
15
|
import { runCli } from './cli-entry.js';
|
|
16
16
|
// ── MCP server ─────────────────────────────────────────────────────────
|
|
@@ -26,6 +26,10 @@ let agentContext = null;
|
|
|
26
26
|
let approvalManager = null;
|
|
27
27
|
let approvalServer = null;
|
|
28
28
|
const conversationCache = new Map();
|
|
29
|
+
const TURN_COMPLETE_METADATA = {
|
|
30
|
+
turnSemantics: 'turn_complete',
|
|
31
|
+
turnComplete: true,
|
|
32
|
+
};
|
|
29
33
|
/** The conversation that most recently triggered work (for approval routing) */
|
|
30
34
|
let triggerConversationId = null;
|
|
31
35
|
function getPrimaryAttachment(message) {
|
|
@@ -221,7 +225,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
221
225
|
case 'reply': {
|
|
222
226
|
const conversationId = args.conversation_id;
|
|
223
227
|
const text = args.text;
|
|
224
|
-
const result = await client.sendMessage(conversationId, text
|
|
228
|
+
const result = await client.sendMessage(conversationId, text, {
|
|
229
|
+
metadata: TURN_COMPLETE_METADATA,
|
|
230
|
+
});
|
|
225
231
|
return {
|
|
226
232
|
content: [
|
|
227
233
|
{
|
|
@@ -261,6 +267,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
261
267
|
else {
|
|
262
268
|
opts.contentType = contentType;
|
|
263
269
|
}
|
|
270
|
+
opts.metadata = {
|
|
271
|
+
...(opts.metadata ?? {}),
|
|
272
|
+
...TURN_COMPLETE_METADATA,
|
|
273
|
+
};
|
|
264
274
|
const result = await client.sendMessage(conversationId, text, opts);
|
|
265
275
|
return {
|
|
266
276
|
content: [
|
|
@@ -288,6 +298,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
288
298
|
const messageOptions = typeof args.message_options === 'object' && args.message_options != null
|
|
289
299
|
? args.message_options
|
|
290
300
|
: undefined;
|
|
301
|
+
const linkedMessageOptions = messageOptions;
|
|
291
302
|
const result = await client.sendLinkedMessage({
|
|
292
303
|
sourceConversationId,
|
|
293
304
|
targetConversationId,
|
|
@@ -308,11 +319,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
308
319
|
targetContext: targetContext,
|
|
309
320
|
}
|
|
310
321
|
: {}),
|
|
311
|
-
...(
|
|
322
|
+
...(linkedMessageOptions
|
|
312
323
|
? {
|
|
313
|
-
messageOptions:
|
|
324
|
+
messageOptions: {
|
|
325
|
+
...linkedMessageOptions,
|
|
326
|
+
metadata: {
|
|
327
|
+
...(linkedMessageOptions.metadata ?? {}),
|
|
328
|
+
...TURN_COMPLETE_METADATA,
|
|
329
|
+
},
|
|
330
|
+
},
|
|
314
331
|
}
|
|
315
|
-
: {}),
|
|
332
|
+
: { messageOptions: { metadata: TURN_COMPLETE_METADATA } }),
|
|
316
333
|
});
|
|
317
334
|
return {
|
|
318
335
|
content: [
|
|
@@ -508,10 +525,14 @@ async function handleInboundMessage(payload) {
|
|
|
508
525
|
async function startChannel() {
|
|
509
526
|
let apiKey;
|
|
510
527
|
let profile;
|
|
528
|
+
let baseUrl;
|
|
529
|
+
let profileAgentName;
|
|
511
530
|
try {
|
|
512
|
-
const resolved = resolveCanonAgent({ logPrefix: '[canon]' });
|
|
531
|
+
const resolved = resolveCanonAgent({ logPrefix: '[canon]', expectedClientType: 'claude-code' });
|
|
513
532
|
apiKey = resolved.apiKey;
|
|
514
533
|
profile = resolved.profile;
|
|
534
|
+
baseUrl = resolved.baseUrl;
|
|
535
|
+
profileAgentName = resolved.agentName;
|
|
515
536
|
}
|
|
516
537
|
catch (err) {
|
|
517
538
|
console.error(err instanceof Error ? err.message : err);
|
|
@@ -520,7 +541,7 @@ async function startChannel() {
|
|
|
520
541
|
if (profile) {
|
|
521
542
|
console.error(`[canon] Using agent profile: ${profile}`);
|
|
522
543
|
}
|
|
523
|
-
client = new CanonClient(apiKey);
|
|
544
|
+
client = new CanonClient(apiKey, baseUrl);
|
|
524
545
|
// Get agent identity — try /agents/me first, fall back to /agents/auth-token
|
|
525
546
|
let agentId;
|
|
526
547
|
try {
|
|
@@ -543,6 +564,27 @@ async function startChannel() {
|
|
|
543
564
|
}
|
|
544
565
|
// Initialize RTDB auth for session state reporting
|
|
545
566
|
initRTDBAuth(client);
|
|
567
|
+
const runtimeId = buildLocalRuntimeId({
|
|
568
|
+
runtime: 'claude-code',
|
|
569
|
+
profile,
|
|
570
|
+
cwd: process.cwd(),
|
|
571
|
+
launchCommand: ['canon-channel-server'],
|
|
572
|
+
});
|
|
573
|
+
upsertLocalRuntimeEntry({
|
|
574
|
+
id: runtimeId,
|
|
575
|
+
runtime: 'claude-code',
|
|
576
|
+
profile,
|
|
577
|
+
agentId,
|
|
578
|
+
agentName: agentContext?.displayName ?? profileAgentName,
|
|
579
|
+
cwd: process.cwd(),
|
|
580
|
+
launchCommand: ['canon-channel-server'],
|
|
581
|
+
pid: process.pid,
|
|
582
|
+
status: 'embedded',
|
|
583
|
+
reviveCapability: 'embedded',
|
|
584
|
+
surfaceMode: 'channel',
|
|
585
|
+
lastStartedAt: new Date().toISOString(),
|
|
586
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
587
|
+
});
|
|
546
588
|
// Populate conversation cache
|
|
547
589
|
try {
|
|
548
590
|
const convos = await client.getConversations();
|
|
@@ -610,13 +652,22 @@ export async function main() {
|
|
|
610
652
|
approvalManager?.dispose();
|
|
611
653
|
approvalServer?.stop().catch(() => { });
|
|
612
654
|
stream?.stop();
|
|
655
|
+
const runtimeId = buildLocalRuntimeId({
|
|
656
|
+
runtime: 'claude-code',
|
|
657
|
+
profile: getActiveProfile(),
|
|
658
|
+
cwd: process.cwd(),
|
|
659
|
+
launchCommand: ['canon-channel-server'],
|
|
660
|
+
});
|
|
661
|
+
markLocalRuntimeStopped(runtimeId);
|
|
613
662
|
const ap = getActiveProfile();
|
|
614
663
|
if (ap)
|
|
615
664
|
releaseLock(ap);
|
|
665
|
+
getActiveProfileLock()?.release();
|
|
616
666
|
process.exit(0);
|
|
617
667
|
};
|
|
618
668
|
process.on('SIGINT', shutdown);
|
|
619
669
|
process.on('SIGTERM', shutdown);
|
|
670
|
+
process.on('SIGHUP', shutdown);
|
|
620
671
|
}
|
|
621
672
|
runCli(import.meta.url, main, (err) => {
|
|
622
673
|
console.error('[canon] Fatal error:', err);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/claude-code-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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,8 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@anthropic-ai/claude-agent-sdk": "^0.2.101",
|
|
34
|
-
"@canonmsg/agent-sdk": "^0.
|
|
35
|
-
"@canonmsg/core": "^0.
|
|
34
|
+
"@canonmsg/agent-sdk": "^0.10.1",
|
|
35
|
+
"@canonmsg/core": "^0.13.0",
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|