@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 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** — Switch model (Haiku/Sonnet/Opus) and permission mode from the app
24
- - **Live streaming** — See Claude's response as it types
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/canon --workspace ~/dev/yumyumv2
46
+ canon-claude --cwd ~/dev --workspace-root ~/dev
39
47
  ```
40
48
 
41
- `--cwd` is the default workspace. Each `--workspace` value appears as a selectable workspace during session creation. Worktree mode creates a per-conversation git worktree under `~/.canon/conversation-worktrees`; shared-workspace mode runs directly in the selected directory.
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
@@ -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, buildConfiguredWorkspaceOptions, buildConversationEnvironmentKey, buildPlanApprovalRequest, buildQuestionRequest, buildPublicWorkspaceOptions, EXECUTION_ENVIRONMENT_MODES, CanonClient, CanonStream, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, ExecutionEnvironmentError, FINAL_MESSAGE_HANDOFF_MS, normalizeTurnMetadata, prepareConversationEnvironment, resolveCanonAgent, getActiveProfile, releaseLock, initRTDBAuth, rtdbRead, rtdbWrite, writeRuntimeInfo, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
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: 'Workspace',
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 workspace',
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 workspace is a git repo.'
77
- : 'Runs directly in the selected workspace. Canon records usage, but does not create a separate checkout.',
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
- return config?.executionMode ?? 'locked';
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 planApproval = buildPlanApprovalRequest(planId);
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
- client.setTyping(conversationId, true, 'thinking').catch(() => { });
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) => console.error(`[canon-host] [${conversationId.slice(0, 8)}] Init failed:`, 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
- workspaceOptions = buildConfiguredWorkspaceOptions(workingDir, configuredWorkspaces);
990
- const { apiKey, agentId: profileAgentId, profile } = resolveCanonAgent({ logPrefix: '[canon-host]' });
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 resumeId = savedSessionIds.get(resumeKey);
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}`).catch(() => { });
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
- const ap = getActiveProfile();
1575
- if (ap)
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
- const ap = getActiveProfile();
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, writeFileSync, mkdirSync } from 'node:fs';
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
- // Save to ~/.canon/agents.json
68
- mkdirSync(CANON_DIR, { recursive: true });
69
- let profiles = {};
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
- catch {
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
- writeFileSync(AGENTS_PATH, JSON.stringify(profiles, null, 2));
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('Restart Claude Code to connect.');
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
- ...(messageOptions
322
+ ...(linkedMessageOptions
312
323
  ? {
313
- messageOptions: 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.10.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.9.0",
35
- "@canonmsg/core": "^0.8.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": {