@canonmsg/claude-code-plugin 0.9.5 โ†’ 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,6 +32,14 @@ Send a message to your agent in the Canon app. Claude Code responds with full ca
32
32
  canon-claude --cwd /path/to/project
33
33
  ```
34
34
 
35
+ Advertise multiple project choices to the Canon app:
36
+
37
+ ```bash
38
+ canon-claude --cwd ~/dev --workspace ~/dev/canon --workspace ~/dev/yumyumv2
39
+ ```
40
+
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.
42
+
35
43
  ## Multiple agents
36
44
 
37
45
  ```bash
@@ -37,6 +37,12 @@ interface HostWorkspaceResolverOption {
37
37
  id: string;
38
38
  cwd: string;
39
39
  }
40
+ export declare const HOST_ADMISSION_ACTION_CAPABILITIES: Readonly<{
41
+ canStartDirectConversation: false;
42
+ canSendContactRequest: false;
43
+ canApprovePendingContactRequests: false;
44
+ canRejectPendingContactRequests: false;
45
+ }>;
40
46
  export declare function buildCanonHostPrompt(input: {
41
47
  hostLabel: string;
42
48
  content: string;
@@ -82,12 +88,33 @@ export declare function buildHydratedInboundContext(input: {
82
88
  hydratedFromPage: boolean;
83
89
  };
84
90
  export declare function publishHostAgentRuntime(agentId: string, clientType: AgentClientType, runtime: AgentRuntime): Promise<void>;
85
- export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null;
91
+ export declare function publishHostSessionSnapshots(input: {
92
+ conversationIds: string[];
93
+ agentId: string;
94
+ clientType: AgentClientType;
95
+ runtime: AgentRuntime;
96
+ workspaceOptions: HostWorkspaceResolverOption[];
97
+ defaultCwd: string;
98
+ liveSessionConfigByConversation?: ReadonlyMap<string, {
99
+ model?: string;
100
+ permissionMode?: string;
101
+ effort?: string;
102
+ runtimeControlValues?: Record<string, string>;
103
+ workspaceId?: string;
104
+ executionMode?: SessionWorkspaceConfig['executionMode'];
105
+ executionBranch?: string | null;
106
+ }>;
107
+ }): Promise<void>;
108
+ export declare function readHostSessionConfig<TExtra extends string = never>(raw: unknown, extraStringFields?: readonly TExtra[]): (SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
109
+ runtimeControlValues?: Record<string, string>;
110
+ }) | null;
86
111
  export declare function loadHostSessionConfig<TExtra extends string = never>(input: {
87
112
  conversationId: string;
88
113
  agentId: string;
89
114
  extraStringFields?: readonly TExtra[];
90
- }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>>) | null>;
115
+ }): Promise<(SessionWorkspaceConfig & Partial<Record<TExtra, string>> & {
116
+ runtimeControlValues?: Record<string, string>;
117
+ }) | null>;
91
118
  export declare function resolveHostWorkspaceCwd(input: {
92
119
  workspaceOptions: HostWorkspaceResolverOption[];
93
120
  config: {
@@ -11,7 +11,13 @@
11
11
  * behavior here, update that copy too and adjust the shared golden
12
12
  * fixture test (`packages/claude-code-plugin/src/host-runtime.test.ts`).
13
13
  */
14
- import { buildBehaviorPolicyLines, buildParticipationHistorySnapshot, buildWorkSessionsPromptLines, mergeWorkSessionContexts, normalizeOptionalString, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, rtdbRead, rtdbWrite, } from '@canonmsg/core';
14
+ import { buildAgentSessionSnapshot, buildConversationWorktreeSpec, buildBehaviorPolicyLines, buildParticipationHistorySnapshot, buildWorkSessionsPromptLines, mergeWorkSessionContexts, normalizeOptionalString, patchAgentSessionSnapshot, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, rtdbRead, rtdbWrite, } from '@canonmsg/core';
15
+ export const HOST_ADMISSION_ACTION_CAPABILITIES = Object.freeze({
16
+ canStartDirectConversation: false,
17
+ canSendContactRequest: false,
18
+ canApprovePendingContactRequests: false,
19
+ canRejectPendingContactRequests: false,
20
+ });
15
21
  export function buildCanonHostPrompt(input) {
16
22
  const resolvedWorkSessions = mergeWorkSessionContexts(input.workSession, input.workSessions);
17
23
  return [
@@ -70,7 +76,21 @@ function describeContactCard(card) {
70
76
  if (card.about)
71
77
  parts.push(`about: ${card.about}`);
72
78
  const identity = `๐Ÿ“‡ Contact card: "${card.displayName}" (${parts.join(' ยท ')}).`;
73
- const hint = `This card is informational only in host mode. Canon does not currently expose a host-side tool here for starting a new direct conversation or sending a contact request to userId ${card.userId}.`;
79
+ const missingCapabilities = [
80
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canStartDirectConversation
81
+ ? 'start a direct conversation'
82
+ : null,
83
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canSendContactRequest
84
+ ? 'send a contact request'
85
+ : null,
86
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canApprovePendingContactRequests
87
+ ? 'approve pending requests'
88
+ : null,
89
+ !HOST_ADMISSION_ACTION_CAPABILITIES.canRejectPendingContactRequests
90
+ ? 'reject pending requests'
91
+ : null,
92
+ ].filter(Boolean).join(', ');
93
+ const hint = `This host can inspect the card, but Canon admission actions are missing here. Missing capabilities: ${missingCapabilities}. Use another Canon surface for userId ${card.userId}.`;
74
94
  return `${identity}\n${hint}`;
75
95
  }
76
96
  function describeAttachment(attachment, materialized) {
@@ -117,6 +137,70 @@ export async function publishHostAgentRuntime(agentId, clientType, runtime) {
117
137
  updatedAt: { '.sv': 'timestamp' },
118
138
  });
119
139
  }
140
+ export async function publishHostSessionSnapshots(input) {
141
+ if (input.conversationIds.length === 0) {
142
+ return;
143
+ }
144
+ await Promise.all(input.conversationIds.map(async (conversationId) => {
145
+ const persistedConfig = await loadHostSessionConfig({
146
+ conversationId,
147
+ agentId: input.agentId,
148
+ extraStringFields: ['permissionMode', 'effort'],
149
+ });
150
+ const liveConfig = input.liveSessionConfigByConversation?.get(conversationId) ?? null;
151
+ const mergedConfig = {
152
+ ...(persistedConfig ?? {}),
153
+ ...(liveConfig ?? {}),
154
+ };
155
+ const snapshot = buildAgentSessionSnapshot({
156
+ conversationId,
157
+ agentId: input.agentId,
158
+ runtime: {
159
+ ...input.runtime,
160
+ clientType: input.clientType,
161
+ hostMode: true,
162
+ },
163
+ sessionConfig: {
164
+ ...(mergedConfig.model ? { model: mergedConfig.model } : {}),
165
+ ...(mergedConfig.permissionMode ? { permissionMode: mergedConfig.permissionMode } : {}),
166
+ ...(mergedConfig.effort ? { effort: mergedConfig.effort } : {}),
167
+ ...(mergedConfig.runtimeControlValues
168
+ ? { runtimeControlValues: mergedConfig.runtimeControlValues }
169
+ : {}),
170
+ ...(mergedConfig.workspaceId ? { workspaceId: mergedConfig.workspaceId } : {}),
171
+ ...(mergedConfig.executionMode ? { executionMode: mergedConfig.executionMode } : {}),
172
+ },
173
+ lastHeartbeatAt: undefined,
174
+ });
175
+ let executionBranch = liveConfig?.executionBranch ?? null;
176
+ if (!executionBranch && snapshot.executionMode === 'worktree' && snapshot.workspaceId) {
177
+ const workspace = input.workspaceOptions.find((option) => option.id === snapshot.workspaceId);
178
+ if (workspace) {
179
+ executionBranch = buildConversationWorktreeSpec({
180
+ agentId: input.agentId,
181
+ conversationId,
182
+ workspaceCwd: workspace.cwd,
183
+ }).branch;
184
+ }
185
+ }
186
+ return patchAgentSessionSnapshot(conversationId, input.agentId, {
187
+ clientType: input.clientType,
188
+ hostMode: true,
189
+ model: snapshot.model ?? null,
190
+ permissionMode: snapshot.permissionMode ?? null,
191
+ effort: snapshot.effort ?? null,
192
+ runtimeControlValues: snapshot.runtimeControlValues ?? null,
193
+ workspaceId: snapshot.workspaceId ?? null,
194
+ executionMode: snapshot.executionMode ?? null,
195
+ executionBranch,
196
+ modelOptions: snapshot.modelOptions,
197
+ permissionModeOptions: snapshot.permissionModeOptions,
198
+ workspaceOptions: snapshot.workspaceOptions,
199
+ availableExecutionModes: snapshot.availableExecutionModes,
200
+ lastHeartbeatAt: { '.sv': 'timestamp' },
201
+ });
202
+ }));
203
+ }
120
204
  export function readHostSessionConfig(raw, extraStringFields = []) {
121
205
  const baseConfig = readSessionWorkspaceConfig(raw);
122
206
  if (!raw || typeof raw !== 'object') {
@@ -127,9 +211,16 @@ export function readHostSessionConfig(raw, extraStringFields = []) {
127
211
  const value = normalizeOptionalString(data[field]);
128
212
  return value ? [[field, value]] : [];
129
213
  }));
214
+ const runtimeControlValues = Object.fromEntries(Object.entries(data.runtimeControlValues && typeof data.runtimeControlValues === 'object'
215
+ ? data.runtimeControlValues
216
+ : {}).flatMap(([key, value]) => {
217
+ const normalizedValue = normalizeOptionalString(value);
218
+ return normalizedValue ? [[key, normalizedValue]] : [];
219
+ }));
130
220
  return {
131
221
  ...(baseConfig ?? {}),
132
222
  ...extraConfig,
223
+ ...(Object.keys(runtimeControlValues).length > 0 ? { runtimeControlValues } : {}),
133
224
  };
134
225
  }
135
226
  export async function loadHostSessionConfig(input) {
package/dist/host.js CHANGED
@@ -24,16 +24,97 @@ import { parseArgs } from 'node:util';
24
24
  import { randomUUID } from 'node:crypto';
25
25
  import { execFileSync } from 'node:child_process';
26
26
  import { existsSync } from 'node:fs';
27
- import { query } from '@anthropic-ai/claude-agent-sdk';
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, 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, writeSessionState, clearSessionState, writeTurnState, releaseConversationEnvironment, } from '@canonmsg/core';
30
- import { buildCanonHostPrompt, buildHydratedInboundContext, createConversationMetadataLoader, loadHostSessionConfig, publishHostAgentRuntime, renderCanonHostInboundContent, resolveHostWorkspaceCwd, } from './host-runtime.js';
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';
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
36
  let runtimeModels = [];
37
+ const CLAUDE_METADATA_TTL_MS = 5 * 60 * 1000;
38
+ let runtimeMetadata = {
39
+ account: null,
40
+ mcpServers: [],
41
+ plugins: [],
42
+ loadedAt: 0,
43
+ };
44
+ let runtimeMetadataRefresh = null;
45
+ function buildClaudeRuntimeDescriptor(input) {
46
+ return {
47
+ coreControls: [
48
+ {
49
+ id: 'model',
50
+ label: 'Model',
51
+ options: input.models,
52
+ defaultValue: input.models[0]?.value ?? null,
53
+ availability: 'setup_and_live',
54
+ liveBehavior: 'immediate',
55
+ selectionPolicy: 'inherit',
56
+ },
57
+ {
58
+ id: 'workspace',
59
+ label: 'Workspace',
60
+ options: input.workspaces.map((workspace) => ({
61
+ value: workspace.id,
62
+ label: workspace.label,
63
+ })),
64
+ defaultValue: input.workspaces[0]?.id ?? null,
65
+ availability: 'setup',
66
+ liveBehavior: 'none',
67
+ selectionPolicy: 'inherit',
68
+ },
69
+ {
70
+ id: 'executionMode',
71
+ label: 'Execution mode',
72
+ options: input.executionModes.map((mode) => ({
73
+ value: mode,
74
+ label: mode === 'worktree' ? 'Isolated worktree' : 'Use shared workspace',
75
+ 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.',
78
+ })),
79
+ defaultValue: null,
80
+ availability: 'setup',
81
+ liveBehavior: 'none',
82
+ selectionPolicy: 'required_explicit',
83
+ },
84
+ ],
85
+ runtimeControls: [
86
+ {
87
+ id: 'permissionMode',
88
+ label: 'Permission mode',
89
+ options: [...CLAUDE_PERMISSION_MODE_OPTIONS],
90
+ defaultValue: 'default',
91
+ availability: 'setup_and_live',
92
+ liveBehavior: 'immediate',
93
+ selectionPolicy: 'inherit',
94
+ },
95
+ {
96
+ id: 'effort',
97
+ label: 'Thinking level',
98
+ options: [
99
+ { value: 'low', label: 'Low' },
100
+ { value: 'medium', label: 'Medium' },
101
+ { value: 'high', label: 'High' },
102
+ ],
103
+ defaultValue: 'medium',
104
+ availability: 'setup_and_live',
105
+ liveBehavior: 'immediate',
106
+ selectionPolicy: 'inherit',
107
+ },
108
+ ],
109
+ supportsInterrupt: true,
110
+ streamingTextMode: 'delta',
111
+ };
112
+ }
113
+ function normalizeClaudeEffortLevel(value) {
114
+ return value === 'low' || value === 'medium' || value === 'high'
115
+ ? value
116
+ : undefined;
117
+ }
37
118
  let cachedClaudeCliPath;
38
119
  // The SDK bundles a Bun-compiled `claude` Mach-O binary next to
39
120
  // claude-agent-sdk-darwin-arm64. On some macOS setups that exact binary gets
@@ -83,11 +164,12 @@ async function loadSessionConfig(conversationId, agentId) {
83
164
  const config = await loadHostSessionConfig({
84
165
  conversationId,
85
166
  agentId,
86
- extraStringFields: ['permissionMode'],
167
+ extraStringFields: ['permissionMode', 'effort'],
87
168
  });
88
169
  return {
89
170
  ...config,
90
171
  permissionMode: config?.permissionMode,
172
+ effort: normalizeClaudeEffortLevel(config?.effort),
91
173
  };
92
174
  }
93
175
  // Default to 'locked' (shared workspace) when no mode has been picked. The
@@ -103,6 +185,14 @@ function resolveWorkspaceCwd(config) {
103
185
  defaultCwd: workingDir,
104
186
  });
105
187
  }
188
+ function resolveExecutionFallbackReason(environment) {
189
+ if (!environment?.reason || environment.mode !== 'locked') {
190
+ return null;
191
+ }
192
+ return environment.reason === 'Sharing the base workspace (locked mode)'
193
+ ? null
194
+ : environment.reason;
195
+ }
106
196
  async function detectRuntimeModels(cwd) {
107
197
  const emptyPrompt = {
108
198
  async *[Symbol.asyncIterator]() { },
@@ -130,6 +220,166 @@ async function detectRuntimeModels(cwd) {
130
220
  catch { }
131
221
  }
132
222
  }
223
+ async function inspectClaudeRuntimeMetadata(cwd, activeQuery) {
224
+ const emptyPrompt = {
225
+ async *[Symbol.asyncIterator]() { },
226
+ };
227
+ const claudeCliPath = resolveClaudeCliPath();
228
+ const probe = activeQuery ?? query({
229
+ prompt: emptyPrompt,
230
+ options: {
231
+ systemPrompt: { type: 'preset', preset: 'claude_code' },
232
+ tools: { type: 'preset', preset: 'claude_code' },
233
+ permissionMode: 'default',
234
+ cwd,
235
+ settings: { forceLoginMethod: 'claudeai' },
236
+ ...(claudeCliPath ? { pathToClaudeCodeExecutable: claudeCliPath } : {}),
237
+ },
238
+ });
239
+ try {
240
+ const [init, mcpServers, pluginInfo] = await Promise.all([
241
+ probe.initializationResult(),
242
+ probe.mcpServerStatus().catch(() => []),
243
+ probe.reloadPlugins().catch(() => null),
244
+ ]);
245
+ return {
246
+ account: init.account ?? null,
247
+ mcpServers: pluginInfo?.mcpServers ?? mcpServers,
248
+ plugins: pluginInfo?.plugins ?? [],
249
+ loadedAt: Date.now(),
250
+ };
251
+ }
252
+ finally {
253
+ if (!activeQuery) {
254
+ try {
255
+ probe.close();
256
+ }
257
+ catch { }
258
+ }
259
+ }
260
+ }
261
+ async function refreshClaudeRuntimeMetadata(input) {
262
+ if (!input.force
263
+ && runtimeMetadata.loadedAt > 0
264
+ && Date.now() - runtimeMetadata.loadedAt < CLAUDE_METADATA_TTL_MS) {
265
+ return runtimeMetadata;
266
+ }
267
+ if (runtimeMetadataRefresh) {
268
+ return runtimeMetadataRefresh;
269
+ }
270
+ runtimeMetadataRefresh = inspectClaudeRuntimeMetadata(input.cwd, input.activeQuery ?? null)
271
+ .then((metadata) => {
272
+ runtimeMetadata = metadata;
273
+ return metadata;
274
+ })
275
+ .finally(() => {
276
+ runtimeMetadataRefresh = null;
277
+ });
278
+ return runtimeMetadataRefresh;
279
+ }
280
+ function mapClaudeMcpStatus(status) {
281
+ switch (status) {
282
+ case 'connected':
283
+ return 'ready';
284
+ case 'needs-auth':
285
+ return 'auth_needed';
286
+ case 'disabled':
287
+ return 'configured';
288
+ case 'failed':
289
+ return 'error';
290
+ default:
291
+ return 'unknown';
292
+ }
293
+ }
294
+ function buildClaudeRuntimeInfo(input) {
295
+ const { descriptor, metadata } = input;
296
+ const statusItems = [
297
+ metadata.account?.email
298
+ ? {
299
+ id: 'account',
300
+ label: 'Account',
301
+ value: metadata.account.email,
302
+ tone: 'success',
303
+ }
304
+ : null,
305
+ metadata.account?.subscriptionType
306
+ ? {
307
+ id: 'subscription',
308
+ label: 'Plan',
309
+ value: metadata.account.subscriptionType,
310
+ }
311
+ : null,
312
+ metadata.account?.apiProvider
313
+ ? {
314
+ id: 'provider',
315
+ label: 'Provider',
316
+ value: metadata.account.apiProvider,
317
+ }
318
+ : null,
319
+ metadata.account?.tokenSource || metadata.account?.apiKeySource
320
+ ? {
321
+ id: 'auth',
322
+ label: 'Auth',
323
+ value: metadata.account.tokenSource ?? metadata.account?.apiKeySource ?? 'connected',
324
+ }
325
+ : null,
326
+ runtimeModels[0]
327
+ ? {
328
+ id: 'fallbackModel',
329
+ label: 'Fallback model',
330
+ value: runtimeModels[0].label,
331
+ }
332
+ : null,
333
+ {
334
+ id: 'toolPolicy',
335
+ label: 'Tool policy',
336
+ value: 'Claude Code preset',
337
+ },
338
+ {
339
+ id: 'browser',
340
+ label: 'Browser tools',
341
+ value: 'Runtime policy',
342
+ },
343
+ ].filter((item) => Boolean(item));
344
+ return {
345
+ descriptor,
346
+ surfaceMode: 'host',
347
+ statusItems,
348
+ inventories: [
349
+ {
350
+ id: 'mcp',
351
+ label: 'MCP',
352
+ entries: metadata.mcpServers.map((server) => ({
353
+ id: server.name,
354
+ label: server.name,
355
+ status: mapClaudeMcpStatus(server.status),
356
+ description: server.error
357
+ ?? server.serverInfo?.version
358
+ ?? server.scope
359
+ ?? undefined,
360
+ })),
361
+ },
362
+ {
363
+ id: 'plugins',
364
+ label: 'Plugins',
365
+ entries: metadata.plugins.map((plugin) => ({
366
+ id: plugin.name,
367
+ label: plugin.name,
368
+ status: 'configured',
369
+ description: plugin.source ?? plugin.path,
370
+ })),
371
+ },
372
+ ],
373
+ execution: {
374
+ resolvedWorkspaceLabel: input.workspaceLabel,
375
+ resolvedCwd: input.resolvedCwd,
376
+ executionMode: input.executionMode,
377
+ executionBranch: input.executionBranch,
378
+ worktreePath: input.worktreePath,
379
+ fallbackReason: input.fallbackReason,
380
+ },
381
+ };
382
+ }
133
383
  // โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
134
384
  const MAX_SESSIONS = 5;
135
385
  const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
@@ -222,7 +472,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
222
472
  // eslint-disable-next-line prefer-const -- session must be declared before queryOptions but assigned after query()
223
473
  let session;
224
474
  const REPLY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
225
- function waitForReply() {
475
+ function waitForReply(type, requestId) {
226
476
  // Keep session alive while waiting for user input
227
477
  session.lastActivity = Date.now();
228
478
  return new Promise((resolve, reject) => {
@@ -231,6 +481,8 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
231
481
  reject(new Error('Timed out waiting for user reply'));
232
482
  }, REPLY_TIMEOUT_MS);
233
483
  session.pendingReply = {
484
+ type,
485
+ requestId,
234
486
  resolve: (metadata) => {
235
487
  clearTimeout(timer);
236
488
  resolve(metadata);
@@ -260,17 +512,16 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
260
512
  try {
261
513
  const questionId = randomUUID();
262
514
  const questions = input.questions ?? [];
263
- await client.sendMessage(conversationId, '', {
515
+ const questionCard = buildQuestionRequest(questionId, questions);
516
+ await client.sendMessage(conversationId, questionCard.text, {
264
517
  metadata: {
265
- type: 'claude_question',
266
- questionId,
267
- questions,
518
+ ...questionCard.metadata,
268
519
  turnSemantics: 'control',
269
520
  replyBehavior: 'suppress_auto_reply',
270
521
  },
271
522
  });
272
523
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Question sent (${questionId.slice(0, 8)})`);
273
- const reply = await waitForReply();
524
+ const reply = await waitForReply('question_reply', questionId);
274
525
  return {
275
526
  behavior: 'allow',
276
527
  updatedInput: { ...input, answers: reply.answers },
@@ -283,16 +534,16 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
283
534
  if (toolName === 'ExitPlanMode') {
284
535
  try {
285
536
  const planId = randomUUID();
286
- await client.sendMessage(conversationId, 'Plan ready for review.', {
537
+ const planApproval = buildPlanApprovalRequest(planId);
538
+ await client.sendMessage(conversationId, planApproval.text, {
287
539
  metadata: {
288
- type: 'plan_approval',
289
- planId,
540
+ ...planApproval.metadata,
290
541
  turnSemantics: 'control',
291
542
  replyBehavior: 'suppress_auto_reply',
292
543
  },
293
544
  });
294
545
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Plan approval sent (${planId.slice(0, 8)})`);
295
- const reply = await waitForReply();
546
+ const reply = await waitForReply('plan_approval_reply', planId);
296
547
  if (reply.decision === 'approve') {
297
548
  return { behavior: 'allow' };
298
549
  }
@@ -467,6 +718,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
467
718
  cwd,
468
719
  executionMode: session.environment.mode,
469
720
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
721
+ ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
470
722
  hostMode: true,
471
723
  isActive: true,
472
724
  }).catch(() => { });
@@ -667,6 +919,15 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
667
919
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial model:`, err);
668
920
  }
669
921
  }
922
+ if (config.effort) {
923
+ try {
924
+ await q.applyFlagSettings({ effortLevel: config.effort });
925
+ session.state.effort = config.effort;
926
+ }
927
+ catch (err) {
928
+ console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to set initial effort:`, err);
929
+ }
930
+ }
670
931
  if (!resumeSessionId) {
671
932
  // New session โ€” send seed message to activate streaming input mode
672
933
  sendInput({
@@ -698,6 +959,7 @@ function createSession(conversationId, environment, agentId, client, onSessionEn
698
959
  cwd,
699
960
  executionMode: session.environment.mode,
700
961
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
962
+ ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
701
963
  hostMode: true,
702
964
  isActive: true,
703
965
  }).catch(() => { });
@@ -758,13 +1020,80 @@ export async function main() {
758
1020
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
759
1021
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
760
1022
  availableExecutionModes: hostAvailableExecutionModes,
1023
+ runtimeDescriptor: buildClaudeRuntimeDescriptor({
1024
+ models: runtimeModels,
1025
+ workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1026
+ executionModes: hostAvailableExecutionModes,
1027
+ }),
761
1028
  };
1029
+ const conversationCache = new Map();
1030
+ const knownConversationIds = new Set();
1031
+ let lastKnownConversationRefreshAt = 0;
762
1032
  const publishRuntimeHeartbeat = async () => {
763
1033
  if (!streamConnected)
764
1034
  return;
1035
+ await refreshKnownConversationIds().catch((error) => {
1036
+ console.error('[canon-host] Failed to refresh known conversations:', error);
1037
+ });
1038
+ const activeSessionQuery = sessions.values().next().value?.query ?? null;
1039
+ const metadata = await refreshClaudeRuntimeMetadata({
1040
+ cwd: workingDir,
1041
+ activeQuery: activeSessionQuery,
1042
+ }).catch((error) => {
1043
+ console.error('[canon-host] Failed to refresh Claude runtime metadata:', error);
1044
+ return runtimeMetadata;
1045
+ });
765
1046
  await publishAgentRuntime(agentId, runtimeDescriptor).catch((error) => {
766
1047
  console.error('[canon-host] Failed to publish agent runtime:', error);
767
1048
  });
1049
+ await publishHostSessionSnapshots({
1050
+ conversationIds: Array.from(knownConversationIds),
1051
+ agentId,
1052
+ clientType: 'claude-code',
1053
+ runtime: runtimeDescriptor,
1054
+ workspaceOptions,
1055
+ defaultCwd: workingDir,
1056
+ liveSessionConfigByConversation: new Map(Array.from(sessions.values()).map((session) => {
1057
+ const workspaceId = resolveWorkspaceIdForBaseCwd(session.environment.baseCwd);
1058
+ return [
1059
+ session.conversationId,
1060
+ {
1061
+ ...(session.state.model ? { model: session.state.model } : {}),
1062
+ ...(session.state.permissionMode ? { permissionMode: session.state.permissionMode } : {}),
1063
+ ...(session.state.effort ? { effort: session.state.effort } : {}),
1064
+ ...(workspaceId ? { workspaceId } : {}),
1065
+ executionMode: session.environment.mode,
1066
+ executionBranch: session.environment.branch ?? null,
1067
+ },
1068
+ ];
1069
+ })),
1070
+ }).catch((error) => {
1071
+ console.error('[canon-host] Failed to publish session snapshots:', error);
1072
+ });
1073
+ await Promise.all(Array.from(knownConversationIds).map(async (conversationId) => {
1074
+ const session = sessions.get(conversationId);
1075
+ const workspaceId = session
1076
+ ? resolveWorkspaceIdForBaseCwd(session.environment.baseCwd)
1077
+ : runtimeDescriptor.defaultWorkspaceId;
1078
+ const workspace = workspaceOptions.find((option) => option.id === workspaceId) ?? null;
1079
+ const payload = buildClaudeRuntimeInfo({
1080
+ descriptor: runtimeDescriptor.runtimeDescriptor ?? buildClaudeRuntimeDescriptor({
1081
+ models: runtimeModels,
1082
+ workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1083
+ executionModes: hostAvailableExecutionModes,
1084
+ }),
1085
+ metadata,
1086
+ workspaceLabel: workspace?.label ?? workspaceId ?? null,
1087
+ resolvedCwd: session?.cwd ?? workspace?.cwd ?? workingDir,
1088
+ executionMode: session?.environment.mode ?? null,
1089
+ executionBranch: session?.environment.branch ?? null,
1090
+ worktreePath: session?.environment.worktreePath ?? null,
1091
+ fallbackReason: resolveExecutionFallbackReason(session?.environment),
1092
+ });
1093
+ await writeRuntimeInfo(conversationId, agentId, payload);
1094
+ })).catch((error) => {
1095
+ console.error('[canon-host] Failed to publish runtime info:', error);
1096
+ });
768
1097
  };
769
1098
  // โ”€โ”€ Load conversations (informational โ€” sessions created on demand) โ”€โ”€
770
1099
  try {
@@ -777,6 +1106,11 @@ export async function main() {
777
1106
  availableModels: runtimeModels,
778
1107
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
779
1108
  availableExecutionModes: hostAvailableExecutionModes,
1109
+ runtimeDescriptor: buildClaudeRuntimeDescriptor({
1110
+ models: runtimeModels,
1111
+ workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1112
+ executionModes: hostAvailableExecutionModes,
1113
+ }),
780
1114
  };
781
1115
  }
782
1116
  catch {
@@ -786,10 +1120,20 @@ export async function main() {
786
1120
  availablePermissionModes: [...CLAUDE_PERMISSION_MODE_OPTIONS],
787
1121
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
788
1122
  availableExecutionModes: hostAvailableExecutionModes,
1123
+ runtimeDescriptor: buildClaudeRuntimeDescriptor({
1124
+ models: runtimeModels,
1125
+ workspaces: buildPublicWorkspaceOptions(workspaceOptions),
1126
+ executionModes: hostAvailableExecutionModes,
1127
+ }),
789
1128
  };
790
1129
  }
791
1130
  try {
792
1131
  const convos = await client.getConversations();
1132
+ lastKnownConversationRefreshAt = Date.now();
1133
+ for (const conversation of convos) {
1134
+ knownConversationIds.add(conversation.id);
1135
+ conversationCache.set(conversation.id, conversation);
1136
+ }
793
1137
  console.error(`[canon-host] ${convos.length} conversations`);
794
1138
  }
795
1139
  catch {
@@ -800,11 +1144,25 @@ export async function main() {
800
1144
  const pendingSessionCreations = new Map();
801
1145
  /** Saved SDK session IDs for resume after idle eviction, keyed by conversation + workspace */
802
1146
  const savedSessionIds = new Map();
803
- const conversationCache = new Map();
804
1147
  const { getConversationMeta } = createConversationMetadataLoader({
805
1148
  client,
806
1149
  conversationCache,
807
1150
  });
1151
+ function resolveWorkspaceIdForBaseCwd(baseCwd) {
1152
+ return workspaceOptions.find((option) => option.cwd === baseCwd)?.id;
1153
+ }
1154
+ async function refreshKnownConversationIds(force = false) {
1155
+ if (!force && Date.now() - lastKnownConversationRefreshAt < HEARTBEAT_MS) {
1156
+ return;
1157
+ }
1158
+ const conversations = await client.getConversations();
1159
+ knownConversationIds.clear();
1160
+ for (const conversation of conversations) {
1161
+ knownConversationIds.add(conversation.id);
1162
+ conversationCache.set(conversation.id, conversation);
1163
+ }
1164
+ lastKnownConversationRefreshAt = Date.now();
1165
+ }
808
1166
  async function loadHydratedInboundContext(input) {
809
1167
  const [conversation, page] = await Promise.all([
810
1168
  getConversationMeta(input.conversationId),
@@ -865,6 +1223,7 @@ export async function main() {
865
1223
  }
866
1224
  }
867
1225
  async function getOrCreateSession(conversationId) {
1226
+ knownConversationIds.add(conversationId);
868
1227
  const existing = sessions.get(conversationId);
869
1228
  if (existing && !existing.closed) {
870
1229
  existing.lastActivity = Date.now();
@@ -908,6 +1267,7 @@ export async function main() {
908
1267
  }, {
909
1268
  model: config?.model ?? runtimeModels[0]?.value,
910
1269
  permissionMode: config?.permissionMode ?? 'default',
1270
+ effort: config?.effort,
911
1271
  availableModels: runtimeModels,
912
1272
  availableExecutionModes: hostAvailableExecutionModes,
913
1273
  }, resumeId);
@@ -937,15 +1297,33 @@ export async function main() {
937
1297
  if (m.senderId === agentId)
938
1298
  return;
939
1299
  const convoId = payload.conversationId;
1300
+ knownConversationIds.add(convoId);
940
1301
  // Check for metadata replies (question answers, plan approvals)
941
1302
  if (m.metadata?.type === 'question_reply' || m.metadata?.type === 'plan_approval_reply') {
942
1303
  const existingSession = sessions.get(convoId);
1304
+ const pendingReply = existingSession?.pendingReply;
1305
+ const isOwnerReply = m.isOwner ?? (ownerId != null && m.senderId === ownerId);
1306
+ const replyRequestId = m.metadata.type === 'question_reply'
1307
+ ? m.metadata.questionId
1308
+ : m.metadata.planId;
1309
+ if (!pendingReply) {
1310
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring stale ${m.metadata.type} with no pending request`);
1311
+ return;
1312
+ }
1313
+ if (!isOwnerReply) {
1314
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring ${m.metadata.type} from non-owner ${m.senderId}`);
1315
+ return;
1316
+ }
1317
+ if (pendingReply.type !== m.metadata.type || pendingReply.requestId !== replyRequestId) {
1318
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Ignoring mismatched ${m.metadata.type} (${String(replyRequestId ?? 'missing-id')})`);
1319
+ return;
1320
+ }
943
1321
  if (existingSession?.pendingReply) {
944
1322
  const { resolve } = existingSession.pendingReply;
945
1323
  existingSession.pendingReply = null;
946
1324
  existingSession.lastActivity = Date.now();
947
1325
  resolve(m.metadata);
948
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type})`);
1326
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Reply received (${m.metadata.type}:${pendingReply.requestId.slice(0, 8)})`);
949
1327
  return;
950
1328
  }
951
1329
  }
@@ -1071,12 +1449,16 @@ export async function main() {
1071
1449
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
1072
1450
  }
1073
1451
  }
1074
- if (ctrl.effort && ctrl.effort !== session.state.effort) {
1075
- session.state.effort = ctrl.effort;
1076
- session.query.applyFlagSettings({
1077
- effortLevel: ctrl.effort,
1078
- }).catch((err) => console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err));
1079
- console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort โ†’ ${ctrl.effort}`);
1452
+ const nextEffort = normalizeClaudeEffortLevel(ctrl.effort);
1453
+ if (nextEffort && nextEffort !== session.state.effort) {
1454
+ try {
1455
+ await session.query.applyFlagSettings({ effortLevel: nextEffort });
1456
+ session.state.effort = nextEffort;
1457
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort โ†’ ${nextEffort}`);
1458
+ }
1459
+ catch (err) {
1460
+ console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
1461
+ }
1080
1462
  }
1081
1463
  writeSessionState(convoId, agentId, {
1082
1464
  ...session.state,
@@ -1084,6 +1466,7 @@ export async function main() {
1084
1466
  cwd: session.cwd,
1085
1467
  executionMode: session.environment.mode,
1086
1468
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1469
+ ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1087
1470
  hostMode: true,
1088
1471
  isActive: true,
1089
1472
  }).catch(() => { });
@@ -1145,6 +1528,7 @@ export async function main() {
1145
1528
  cwd: session.cwd,
1146
1529
  executionMode: session.environment.mode,
1147
1530
  ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1531
+ ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1148
1532
  hostMode: true,
1149
1533
  isActive: true,
1150
1534
  }).catch(() => { });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "Canon channel plugin for Claude Code โ€” messaging where AI agents are first-class citizens",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
34
- "@canonmsg/agent-sdk": "^0.8.2",
35
- "@canonmsg/core": "^0.7.4",
34
+ "@canonmsg/agent-sdk": "^0.9.0",
35
+ "@canonmsg/core": "^0.8.0",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {