@canonmsg/claude-code-plugin 0.12.4 → 0.12.6

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.
@@ -1,2 +1,2 @@
1
- export declare function isDirectExecution(moduleUrl: string): boolean;
2
- export declare function runCli(moduleUrl: string, main: () => void | Promise<void>, onError: (error: unknown) => void): void;
1
+ export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
2
+ export type { CliMetadata, RunCliMetadataOptions, } from '@canonmsg/core';
package/dist/cli-entry.js CHANGED
@@ -1,26 +1 @@
1
- import { realpathSync } from 'node:fs';
2
- import { resolve } from 'node:path';
3
- import { pathToFileURL } from 'node:url';
4
- export function isDirectExecution(moduleUrl) {
5
- const entry = process.argv[1];
6
- if (!entry)
7
- return false;
8
- const abs = resolve(entry);
9
- let real = abs;
10
- try {
11
- real = realpathSync(abs);
12
- }
13
- catch {
14
- // argv[1] may not exist on disk in rare harnesses; fall back to abs
15
- }
16
- return (pathToFileURL(real).href === moduleUrl ||
17
- pathToFileURL(abs).href === moduleUrl);
18
- }
19
- export function runCli(moduleUrl, main, onError) {
20
- if (!isDirectExecution(moduleUrl)) {
21
- return;
22
- }
23
- Promise.resolve()
24
- .then(() => main())
25
- .catch(onError);
26
- }
1
+ export { handleCliMetadataRequest, isDirectExecution, readCliPackageVersion, runCli, } from '@canonmsg/core';
package/dist/host.js CHANGED
@@ -30,6 +30,28 @@ import { CLAUDE_PERMISSION_MODE_OPTIONS, buildCanonHostPrompt, buildConfiguredWo
30
30
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
31
31
  import { createStreamingBuffer } from './streaming-buffer.js';
32
32
  import { runCli } from './cli-entry.js';
33
+ import { buildClaudeFinalMessageId, createClaudeInputEnvelope, formatClaudeControlError, mergeClaudeModelOptions, shouldDeliverClaudeFinal, } from './session-state.js';
34
+ const HELP = `canon-claude — run a local Claude Code agent host for Canon
35
+
36
+ USAGE
37
+ canon-claude [flags]
38
+
39
+ COMMON FLAGS
40
+ --cwd <path> Project directory to run Claude Code from
41
+ --workspace <path> Additional project to expose in Canon
42
+ --workspace-root <path> Discover projects under an approved root
43
+ --help, -h Show this help
44
+ --version, -V Show package version
45
+
46
+ AUTH
47
+ CANON_AGENT=<profile> canon-claude --cwd /path/to/project
48
+ CANON_API_KEY=agk_live_... canon-claude --cwd /path/to/project
49
+
50
+ EXAMPLES
51
+ canon-claude --cwd ~/dev/canon
52
+ canon-claude --cwd ~/dev/canon --workspace-root ~/dev
53
+
54
+ Keep this terminal open while you want Canon to reach the agent.`;
33
55
  let workingDir = process.cwd();
34
56
  let workspaceOptions = [];
35
57
  let workspaceRoots = [];
@@ -344,8 +366,9 @@ function buildClaudeRuntimeInfo(input) {
344
366
  ? {
345
367
  id: 'account',
346
368
  label: 'Account',
347
- value: metadata.account.email,
369
+ value: 'Connected',
348
370
  tone: 'success',
371
+ tier: 'diagnostic',
349
372
  }
350
373
  : null,
351
374
  metadata.account?.subscriptionType
@@ -367,6 +390,7 @@ function buildClaudeRuntimeInfo(input) {
367
390
  id: 'auth',
368
391
  label: 'Auth',
369
392
  value: metadata.account.tokenSource ?? metadata.account?.apiKeySource ?? 'connected',
393
+ tier: 'diagnostic',
370
394
  }
371
395
  : null,
372
396
  runtimeModels[0]
@@ -374,17 +398,21 @@ function buildClaudeRuntimeInfo(input) {
374
398
  id: 'fallbackModel',
375
399
  label: 'Fallback model',
376
400
  value: runtimeModels[0].label,
401
+ tier: 'diagnostic',
402
+ source: 'host-default',
377
403
  }
378
404
  : null,
379
405
  {
380
406
  id: 'toolPolicy',
381
407
  label: 'Tool policy',
382
408
  value: 'Claude Code preset',
409
+ tier: 'detail',
383
410
  },
384
411
  {
385
412
  id: 'browser',
386
413
  label: 'Browser tools',
387
414
  value: 'Runtime policy',
415
+ tier: 'detail',
388
416
  },
389
417
  ].filter((item) => Boolean(item));
390
418
  return {
@@ -404,6 +432,7 @@ function buildClaudeRuntimeInfo(input) {
404
432
  ?? server.scope
405
433
  ?? undefined,
406
434
  })),
435
+ tier: 'diagnostic',
407
436
  },
408
437
  {
409
438
  id: 'plugins',
@@ -414,6 +443,7 @@ function buildClaudeRuntimeInfo(input) {
414
443
  status: 'configured',
415
444
  description: plugin.source ?? plugin.path,
416
445
  })),
446
+ tier: 'diagnostic',
417
447
  },
418
448
  {
419
449
  id: 'commands',
@@ -424,6 +454,7 @@ function buildClaudeRuntimeInfo(input) {
424
454
  status: 'configured',
425
455
  description: command.description ?? command.argumentHint ?? undefined,
426
456
  })),
457
+ tier: 'diagnostic',
427
458
  },
428
459
  {
429
460
  id: 'agents',
@@ -434,6 +465,7 @@ function buildClaudeRuntimeInfo(input) {
434
465
  status: 'configured',
435
466
  description: agent.description ?? agent.model ?? undefined,
436
467
  })),
468
+ tier: 'diagnostic',
437
469
  },
438
470
  ],
439
471
  execution: {
@@ -465,12 +497,40 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
465
497
  supportsNonFinalPermanentMessages: false,
466
498
  };
467
499
  function buildCanonPrompt(input) {
500
+ const { session, ...promptInput } = input;
468
501
  return buildCanonHostPrompt({
469
502
  hostLabel: 'Claude Code',
470
503
  buildInboundContextLines,
471
- ...input,
504
+ sessionContextLines: session ? buildClaudeSessionContextLines(session) : undefined,
505
+ ...promptInput,
472
506
  });
473
507
  }
508
+ function optionLabel(value, options) {
509
+ if (!value)
510
+ return null;
511
+ const label = options.find((option) => option.value === value)?.label ?? value;
512
+ return label === value ? value : `${label} (${value})`;
513
+ }
514
+ function buildClaudeSessionContextLines(session) {
515
+ const workspace = workspaceOptions.find((option) => option.cwd === session.environment.baseCwd);
516
+ const permissionMode = optionLabel(session.state.permissionMode, CLAUDE_PERMISSION_MODE_OPTIONS);
517
+ const effort = optionLabel(session.state.effort, [
518
+ { value: 'low', label: 'Low' },
519
+ { value: 'medium', label: 'Medium' },
520
+ { value: 'high', label: 'High' },
521
+ ]);
522
+ const model = optionLabel(session.state.model, session.availableModels);
523
+ return [
524
+ model ? `Selected model: ${model}` : null,
525
+ permissionMode ? `Permission mode: ${permissionMode}` : null,
526
+ effort ? `Thinking level: ${effort}` : null,
527
+ workspace ? `Project: ${workspace.label} (${workspace.id})` : null,
528
+ `Execution mode: ${session.environment.mode}`,
529
+ `Working directory: ${session.cwd}`,
530
+ session.environment.branch ? `Git branch: ${session.environment.branch}` : null,
531
+ session.environment.worktreePath ? `Worktree: ${session.environment.worktreePath}` : null,
532
+ ].filter((line) => Boolean(line));
533
+ }
474
534
  function renderInboundContent(message, materialized) {
475
535
  return renderCanonHostInboundContent(message, materialized);
476
536
  }
@@ -501,19 +561,25 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
501
561
  let resolveInput = null;
502
562
  let inputDone = false;
503
563
  const messageQueue = [];
504
- const sendInput = (msg) => {
564
+ const sendInput = (input) => {
505
565
  if (resolveInput) {
506
566
  const r = resolveInput;
507
567
  resolveInput = null;
508
- r(msg);
568
+ r(input);
509
569
  }
510
570
  else {
511
571
  // Queue if iterator isn't waiting yet (e.g., during init)
512
- messageQueue.push(msg);
572
+ messageQueue.push(input);
513
573
  }
514
574
  };
515
- let enqueueInboundMessage = (msg, _intent = 'queue', _sourceMessageId, _markAccepted = false) => {
516
- sendInput(msg);
575
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false) => {
576
+ sendInput(createClaudeInputEnvelope({
577
+ kind: 'canon',
578
+ msg,
579
+ intent,
580
+ sourceMessageId,
581
+ markAccepted,
582
+ }));
517
583
  };
518
584
  const inputStream = {
519
585
  [Symbol.asyncIterator]() {
@@ -523,10 +589,15 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
523
589
  return Promise.resolve({ done: true, value: undefined });
524
590
  // Drain queued messages first (sent before iterator was ready)
525
591
  if (messageQueue.length > 0) {
526
- return Promise.resolve({ done: false, value: messageQueue.shift() });
592
+ const next = messageQueue.shift();
593
+ session.activeInput = next;
594
+ return Promise.resolve({ done: false, value: next.msg });
527
595
  }
528
596
  return new Promise((resolve) => {
529
- resolveInput = (msg) => resolve({ done: false, value: msg });
597
+ resolveInput = (input) => {
598
+ session.activeInput = input;
599
+ resolve({ done: false, value: input.msg });
600
+ };
530
601
  });
531
602
  },
532
603
  return() {
@@ -667,8 +738,9 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
667
738
  sendInput,
668
739
  enqueueInbound: (msg, intent = 'queue', sourceMessageId, markAccepted = false) => enqueueInboundMessage(msg, intent, sourceMessageId, markAccepted),
669
740
  state: {
670
- model: config.model,
741
+ model: undefined,
671
742
  permissionMode: config.permissionMode,
743
+ effort: undefined,
672
744
  state: 'idle',
673
745
  },
674
746
  availableModels: config.availableModels,
@@ -678,8 +750,12 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
678
750
  finalDeliveryTimer: null,
679
751
  seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
680
752
  pendingInputs: [],
753
+ activeInput: null,
754
+ finalizedTurnKeys: new Set(),
755
+ interruptedTurnKeys: new Set(),
681
756
  pendingFinalText: null,
682
- pendingFinalDeliveryText: null,
757
+ pendingFinalDelivery: null,
758
+ runtimeControlErrors: {},
683
759
  toolInProgress: false,
684
760
  turnState: 'idle',
685
761
  currentTurnId: null,
@@ -690,6 +766,10 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
690
766
  closed: false,
691
767
  sdkSessionId: resumeSessionId || null,
692
768
  pendingReply: null,
769
+ writeState: () => writeState(),
770
+ writeTurn: () => writeTurn(),
771
+ setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
772
+ clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
693
773
  };
694
774
  const streamingBuffer = createStreamingBuffer({
695
775
  throttleMs: STREAMING_THROTTLE_MS,
@@ -739,7 +819,8 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
739
819
  session.currentTurnOpenedAt = null;
740
820
  session.lastAcceptedIntent = null;
741
821
  session.pendingFinalText = null;
742
- session.pendingFinalDeliveryText = null;
822
+ session.pendingFinalDelivery = null;
823
+ session.activeInput = null;
743
824
  session.toolInProgress = false;
744
825
  session.turnState = 'idle';
745
826
  stopVisibleWorkSignal();
@@ -755,10 +836,18 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
755
836
  resetTurnToIdle();
756
837
  }, FINAL_MESSAGE_HANDOFF_MS);
757
838
  }
758
- function markPendingFinalDelivery(finalText) {
839
+ function markPendingFinalDelivery(finalText, turn) {
759
840
  clearIdleResetTimer();
760
841
  session.pendingFinalText = finalText;
761
- session.pendingFinalDeliveryText = finalText;
842
+ session.pendingFinalDelivery = {
843
+ turnKey: turn.turnKey,
844
+ text: finalText,
845
+ messageId: buildClaudeFinalMessageId({
846
+ agentId,
847
+ conversationId,
848
+ turnKey: turn.turnKey,
849
+ }),
850
+ };
762
851
  session.state.state = 'running';
763
852
  session.turnState = 'streaming';
764
853
  stopVisibleWorkSignal();
@@ -767,49 +856,96 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
767
856
  writeTurn();
768
857
  client.setTyping(conversationId, true, 'typing').catch(() => { });
769
858
  }
770
- async function deliverFinalReply(finalText) {
859
+ async function deliverFinalReply(finalText, turn = session.activeInput) {
860
+ if (!shouldDeliverClaudeFinal({
861
+ turn,
862
+ finalizedTurnKeys: session.finalizedTurnKeys,
863
+ interruptedTurnKeys: session.interruptedTurnKeys,
864
+ })) {
865
+ return true;
866
+ }
867
+ const deliverTurn = turn;
868
+ if (!deliverTurn)
869
+ return true;
870
+ const finalMessageId = buildClaudeFinalMessageId({
871
+ agentId,
872
+ conversationId,
873
+ turnKey: deliverTurn.turnKey,
874
+ });
771
875
  try {
772
876
  await client.sendMessage(conversationId, finalText, {
877
+ messageId: finalMessageId,
773
878
  metadata: {
774
879
  turnId: session.currentTurnId,
880
+ turnKey: deliverTurn.turnKey,
881
+ sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
775
882
  turnSemantics: 'turn_complete',
776
883
  turnComplete: true,
777
884
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
778
885
  },
779
886
  });
887
+ session.finalizedTurnKeys.add(deliverTurn.turnKey);
780
888
  session.state.state = 'idle';
781
- session.pendingFinalDeliveryText = null;
889
+ session.pendingFinalDelivery = null;
782
890
  clearFinalDeliveryTimer();
783
891
  writeState();
784
892
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
785
893
  return true;
786
894
  }
787
895
  catch (err) {
788
- markPendingFinalDelivery(finalText);
896
+ markPendingFinalDelivery(finalText, deliverTurn);
789
897
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
790
898
  return false;
791
899
  }
792
900
  }
793
- function scheduleFinalDeliveryRetry(finalText) {
794
- if (session.closed || session.pendingFinalDeliveryText !== finalText)
901
+ function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
902
+ if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
795
903
  return;
796
904
  clearFinalDeliveryTimer();
797
905
  session.finalDeliveryTimer = setTimeout(() => {
798
906
  session.finalDeliveryTimer = null;
799
- void deliverFinalReply(finalText).then((sent) => {
907
+ const retryTurn = session.activeInput?.turnKey === pending.turnKey
908
+ ? session.activeInput
909
+ : null;
910
+ void deliverFinalReply(pending.text, retryTurn).then((sent) => {
800
911
  if (sent) {
801
912
  scheduleFinalHandoffReset();
802
913
  return;
803
914
  }
804
- scheduleFinalDeliveryRetry(finalText);
915
+ scheduleFinalDeliveryRetry();
805
916
  });
806
917
  }, FINAL_DELIVERY_RETRY_MS);
807
918
  }
808
919
  function writeState() {
920
+ const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
921
+ ? session.runtimeControlErrors
922
+ : null;
923
+ const controlState = {};
924
+ const appliedAt = Date.now();
925
+ if (session.state.model !== undefined) {
926
+ controlState.model = { value: session.state.model, source: 'applied', appliedAt };
927
+ }
928
+ else if (config.model !== undefined) {
929
+ controlState.model = { value: config.model, source: 'requested' };
930
+ }
931
+ if (session.state.permissionMode !== undefined) {
932
+ controlState.permissionMode = { value: session.state.permissionMode, source: 'applied', appliedAt };
933
+ }
934
+ else if (config.permissionMode !== undefined) {
935
+ controlState.permissionMode = { value: config.permissionMode, source: 'requested' };
936
+ }
937
+ if (session.state.effort !== undefined) {
938
+ controlState.effort = { value: session.state.effort, source: 'applied', appliedAt };
939
+ }
940
+ else if (config.effort !== undefined) {
941
+ controlState.effort = { value: config.effort, source: 'requested' };
942
+ }
809
943
  runtimeState.writeSessionState(conversationId, {
810
944
  model: session.state.model,
811
945
  permissionMode: session.state.permissionMode,
812
946
  effort: session.state.effort,
947
+ controlState,
948
+ runtimeControlErrors,
813
949
  contextUsage: session.state.contextUsage,
814
950
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
815
951
  cwd,
@@ -823,6 +959,25 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
823
959
  isActive: true,
824
960
  }).catch(() => { });
825
961
  }
962
+ function setRuntimeControlError(controlId, value, error) {
963
+ session.runtimeControlErrors = {
964
+ ...session.runtimeControlErrors,
965
+ [controlId]: {
966
+ value,
967
+ message: formatClaudeControlError(error),
968
+ updatedAt: Date.now(),
969
+ },
970
+ };
971
+ writeState();
972
+ }
973
+ function clearRuntimeControlError(controlId) {
974
+ if (!(controlId in session.runtimeControlErrors))
975
+ return;
976
+ const next = { ...session.runtimeControlErrors };
977
+ delete next[controlId];
978
+ session.runtimeControlErrors = next;
979
+ writeState();
980
+ }
826
981
  function writeTurn() {
827
982
  runtimeState.writeTurnState(conversationId, {
828
983
  turnId: session.currentTurnId,
@@ -870,22 +1025,32 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
870
1025
  session.lastAcceptedIntent = next.intent;
871
1026
  writeTurn();
872
1027
  void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
873
- .finally(() => sendInput(next.msg));
1028
+ .finally(() => sendInput(next));
874
1029
  }
875
1030
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
876
1031
  session.lastActivity = Date.now();
877
- if (session.pendingFinalDeliveryText) {
878
- session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
1032
+ const input = createClaudeInputEnvelope({
1033
+ kind: 'canon',
1034
+ msg,
1035
+ intent,
1036
+ sourceMessageId,
1037
+ markAccepted,
1038
+ });
1039
+ if (session.pendingFinalDelivery) {
1040
+ session.pendingInputs.push(input);
879
1041
  writeTurn();
880
1042
  return;
881
1043
  }
882
1044
  if (session.state.state === 'running' && intent !== 'interrupt') {
883
- session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
1045
+ session.pendingInputs.push(input);
884
1046
  writeTurn();
885
1047
  return;
886
1048
  }
887
1049
  if (session.state.state === 'running' && intent === 'interrupt') {
888
- session.pendingInputs.unshift({ msg, intent, sourceMessageId, markAccepted });
1050
+ if (session.activeInput) {
1051
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
1052
+ }
1053
+ session.pendingInputs.unshift(input);
889
1054
  session.turnState = 'interrupted';
890
1055
  stopVisibleWorkSignal();
891
1056
  writeTurn();
@@ -895,7 +1060,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
895
1060
  session.lastAcceptedIntent = intent;
896
1061
  writeTurn();
897
1062
  void markQueuedMessageAccepted(sourceMessageId, markAccepted)
898
- .finally(() => sendInput(msg));
1063
+ .finally(() => sendInput(input));
899
1064
  };
900
1065
  writeTurn();
901
1066
  // ── Process SDK output messages ──
@@ -991,12 +1156,22 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
991
1156
  const resultSessionId = msg.session_id;
992
1157
  if (resultSessionId)
993
1158
  session.sdkSessionId = resultSessionId;
1159
+ const completedInput = session.activeInput;
994
1160
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
995
1161
  const resultText = typeof msg.result === 'string'
996
1162
  ? msg.result.trim()
997
1163
  : '';
998
1164
  const finalText = resultText || session.pendingFinalText?.trim() || null;
999
- const finalDelivered = finalText ? await deliverFinalReply(finalText) : true;
1165
+ const shouldDeliverFinal = finalText
1166
+ ? shouldDeliverClaudeFinal({
1167
+ turn: completedInput,
1168
+ finalizedTurnKeys: session.finalizedTurnKeys,
1169
+ interruptedTurnKeys: session.interruptedTurnKeys,
1170
+ })
1171
+ : false;
1172
+ const finalDelivered = finalText && shouldDeliverFinal
1173
+ ? await deliverFinalReply(finalText, completedInput)
1174
+ : true;
1000
1175
  try {
1001
1176
  const usage = await q.getContextUsage();
1002
1177
  session.state.contextUsage = {
@@ -1011,10 +1186,19 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1011
1186
  // Non-fatal
1012
1187
  }
1013
1188
  if (finalDelivered) {
1014
- scheduleFinalHandoffReset();
1189
+ if (completedInput?.turnKey === session.activeInput?.turnKey) {
1190
+ session.activeInput = null;
1191
+ }
1192
+ session.pendingFinalText = null;
1193
+ if (shouldDeliverFinal && finalText) {
1194
+ scheduleFinalHandoffReset();
1195
+ }
1196
+ else {
1197
+ resetTurnToIdle();
1198
+ }
1015
1199
  }
1016
1200
  else if (finalText) {
1017
- scheduleFinalDeliveryRetry(finalText);
1201
+ scheduleFinalDeliveryRetry();
1018
1202
  }
1019
1203
  break;
1020
1204
  }
@@ -1056,24 +1240,31 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1056
1240
  }
1057
1241
  if (!resumeSessionId) {
1058
1242
  // New session — send seed message to activate streaming input mode
1059
- sendInput({
1060
- type: 'user',
1061
- message: {
1062
- role: 'user',
1063
- content: 'You are connected to Canon messaging. Follow-up messages in this session come from Canon participants, and your text responses will be sent back to the conversation. You have full Claude Code capabilities (file read/write, bash, etc).',
1243
+ sendInput(createClaudeInputEnvelope({
1244
+ kind: 'seed',
1245
+ msg: {
1246
+ type: 'user',
1247
+ message: {
1248
+ role: 'user',
1249
+ content: 'You are connected to Canon messaging. Follow-up messages in this session come from Canon participants, and your text responses will be sent back to the conversation. You have full Claude Code capabilities (file read/write, bash, etc).',
1250
+ },
1251
+ parent_tool_use_id: null,
1064
1252
  },
1065
- parent_tool_use_id: null,
1066
- });
1253
+ }));
1067
1254
  }
1068
1255
  // Refresh the canonical runtime descriptor after Claude reports supported models.
1069
1256
  try {
1070
1257
  const models = await q.supportedModels();
1071
- const modelList = toModelOptions(models);
1258
+ const modelList = mergeClaudeModelOptions({
1259
+ previous: runtimeModels,
1260
+ next: toModelOptions(models),
1261
+ selectedModel: session.state.model,
1262
+ });
1072
1263
  session.availableModels = modelList;
1073
1264
  runtimeModels = modelList;
1074
1265
  onRuntimeDescriptorUpdate({
1075
1266
  defaultWorkspaceId: workspaceOptions[0]?.id,
1076
- defaultModel: session.state.model ?? modelList[0]?.value,
1267
+ defaultModel: modelList[0]?.value,
1077
1268
  defaultPermissionMode: 'default',
1078
1269
  availableModels: modelList,
1079
1270
  availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
@@ -1085,21 +1276,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1085
1276
  executionModes: config.availableExecutionModes,
1086
1277
  }),
1087
1278
  });
1088
- runtimeState.writeSessionState(conversationId, {
1089
- model: session.state.model,
1090
- permissionMode: session.state.permissionMode,
1091
- effort: session.state.effort,
1092
- contextUsage: session.state.contextUsage,
1093
- cwd,
1094
- executionMode: session.environment.mode,
1095
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1096
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1097
- ...(resolveExecutionFallbackReason(session.environment)
1098
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
1099
- : {}),
1100
- hostMode: true,
1101
- isActive: true,
1102
- }).catch(() => { });
1279
+ writeState();
1103
1280
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
1104
1281
  }
1105
1282
  catch {
@@ -1513,7 +1690,10 @@ export async function main() {
1513
1690
  availableExecutionModes: hostAvailableExecutionModes,
1514
1691
  }, resumeId);
1515
1692
  sessions.set(conversationId, session);
1516
- await baselineControlSignal(conversationId);
1693
+ await Promise.all([
1694
+ baselineControlSignal(conversationId),
1695
+ baselineSessionControl(conversationId),
1696
+ ]);
1517
1697
  return session;
1518
1698
  }
1519
1699
  catch (error) {
@@ -1540,6 +1720,17 @@ export async function main() {
1540
1720
  lastSeenSignal.set(conversationId, timestamp);
1541
1721
  }
1542
1722
  }
1723
+ async function baselineSessionControl(conversationId) {
1724
+ if (lastSeenControl.has(conversationId))
1725
+ return;
1726
+ const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
1727
+ if (!raw || typeof raw !== 'object')
1728
+ return;
1729
+ const timestamp = Number(raw.updatedAt ?? 0);
1730
+ if (timestamp > 0) {
1731
+ lastSeenControl.set(conversationId, timestamp);
1732
+ }
1733
+ }
1543
1734
  async function enqueueRecoveredInboundMessage(input) {
1544
1735
  const m = input.message;
1545
1736
  if (m.senderId === agentId)
@@ -1599,6 +1790,7 @@ export async function main() {
1599
1790
  behavior,
1600
1791
  workSession: m.workSession,
1601
1792
  workSessions,
1793
+ session,
1602
1794
  });
1603
1795
  const messageContent = await buildCanonUserContent({ promptText, materialized });
1604
1796
  session.enqueueInbound({
@@ -1747,6 +1939,7 @@ export async function main() {
1747
1939
  behavior,
1748
1940
  workSession: m.workSession,
1749
1941
  workSessions,
1942
+ session,
1750
1943
  });
1751
1944
  const messageContent = await buildCanonUserContent({ promptText, materialized });
1752
1945
  session.enqueueInbound({
@@ -1801,9 +1994,11 @@ export async function main() {
1801
1994
  try {
1802
1995
  await session.query.setModel(ctrl.model);
1803
1996
  session.state.model = ctrl.model;
1997
+ session.clearRuntimeControlError('model');
1804
1998
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
1805
1999
  }
1806
2000
  catch (err) {
2001
+ session.setRuntimeControlError('model', ctrl.model, err);
1807
2002
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
1808
2003
  }
1809
2004
  }
@@ -1811,9 +2006,11 @@ export async function main() {
1811
2006
  try {
1812
2007
  await session.query.setPermissionMode(ctrl.permissionMode);
1813
2008
  session.state.permissionMode = ctrl.permissionMode;
2009
+ session.clearRuntimeControlError('permissionMode');
1814
2010
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${ctrl.permissionMode}`);
1815
2011
  }
1816
2012
  catch (err) {
2013
+ session.setRuntimeControlError('permissionMode', ctrl.permissionMode, err);
1817
2014
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
1818
2015
  }
1819
2016
  }
@@ -1822,27 +2019,19 @@ export async function main() {
1822
2019
  try {
1823
2020
  await session.query.applyFlagSettings({ effortLevel: nextEffort });
1824
2021
  session.state.effort = nextEffort;
2022
+ session.clearRuntimeControlError('effort');
1825
2023
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
1826
2024
  }
1827
2025
  catch (err) {
2026
+ session.setRuntimeControlError('effort', nextEffort, err);
1828
2027
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
1829
2028
  }
1830
2029
  }
1831
- runtimeState.writeSessionState(convoId, {
1832
- model: session.state.model,
1833
- permissionMode: session.state.permissionMode,
1834
- effort: session.state.effort,
1835
- contextUsage: session.state.contextUsage,
1836
- cwd: session.cwd,
1837
- executionMode: session.environment.mode,
1838
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1839
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1840
- ...(resolveExecutionFallbackReason(session.environment)
1841
- ? { executionFallbackReason: resolveExecutionFallbackReason(session.environment) ?? undefined }
1842
- : {}),
1843
- hostMode: true,
1844
- isActive: true,
1845
- }).catch(() => { });
2030
+ else if (ctrl.effort && !nextEffort) {
2031
+ session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2032
+ }
2033
+ session.writeState();
2034
+ await rtdbWrite(`/control/${convoId}/${agentId}/session`, null).catch(() => { });
1846
2035
  }
1847
2036
  }
1848
2037
  }
@@ -1866,6 +2055,10 @@ export async function main() {
1866
2055
  const droppedInputs = session.pendingInputs.splice(0);
1867
2056
  await markSessionPendingInputsRejected(convoId, droppedInputs);
1868
2057
  }
2058
+ if (session.activeInput) {
2059
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
2060
+ }
2061
+ session.pendingFinalText = null;
1869
2062
  session.turnState = 'interrupted';
1870
2063
  runtimeState.writeTurnState(convoId, {
1871
2064
  turnId: session.currentTurnId,
@@ -1907,30 +2100,8 @@ export async function main() {
1907
2100
  // ── Heartbeat (per-session) ──
1908
2101
  const heartbeat = setInterval(() => {
1909
2102
  for (const session of sessions.values()) {
1910
- runtimeState.writeSessionState(session.conversationId, {
1911
- model: session.state.model,
1912
- permissionMode: session.state.permissionMode,
1913
- effort: session.state.effort,
1914
- contextUsage: session.state.contextUsage,
1915
- cwd: session.cwd,
1916
- executionMode: session.environment.mode,
1917
- ...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
1918
- ...(session.environment.worktreePath ? { worktreePath: session.environment.worktreePath } : {}),
1919
- hostMode: true,
1920
- isActive: true,
1921
- }).catch(() => { });
1922
- runtimeState.writeTurnState(session.conversationId, {
1923
- turnId: session.currentTurnId,
1924
- state: session.turnState,
1925
- queueDepth: session.pendingInputs.length,
1926
- currentSpeakerId: agentId,
1927
- lastAcceptedIntent: session.lastAcceptedIntent,
1928
- capabilities: CLAUDE_RUNTIME_CAPABILITIES,
1929
- ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
1930
- ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
1931
- ? { completedAt: { '.sv': 'timestamp' } }
1932
- : {}),
1933
- }).catch(() => { });
2103
+ session.writeState();
2104
+ session.writeTurn();
1934
2105
  }
1935
2106
  void publishRuntimeHeartbeat();
1936
2107
  }, HEARTBEAT_MS);
@@ -1971,7 +2142,11 @@ export async function main() {
1971
2142
  }
1972
2143
  // ── Entry ───────────────────────────────────────────────────────────
1973
2144
  runCli(import.meta.url, main, (err) => {
1974
- console.error('[canon-host] Fatal error:', err);
2145
+ const message = err instanceof Error ? err.message : String(err);
2146
+ console.error(`[canon-host] ${message}`);
1975
2147
  getActiveProfileLock()?.release();
1976
2148
  process.exit(1);
2149
+ }, {
2150
+ name: 'canon-claude',
2151
+ help: HELP,
1977
2152
  });
package/dist/register.js CHANGED
@@ -13,6 +13,27 @@ import { ackRegistrationApproval, clearPendingRegistration, getOrCreatePendingRe
13
13
  import { readFileSync } from 'node:fs';
14
14
  import { parseArgs } from 'node:util';
15
15
  import { runCli } from './cli-entry.js';
16
+ const HELP = `canon-register — register or reconnect a Claude Code agent in Canon
17
+
18
+ USAGE
19
+ canon-register --name <name> --description <text> --phone <e164> [flags]
20
+
21
+ REQUIRED
22
+ --name <name> Agent display name shown in Canon
23
+ --description <text> Short profile description
24
+ --phone <e164> Owner phone number, for example +15551234567
25
+
26
+ FLAGS
27
+ --profile <name> Local profile name in ~/.canon/agents.json
28
+ --base-url <url> Canon API base URL override
29
+ --help, -h Show this help
30
+ --version, -V Show package version
31
+
32
+ EXAMPLES
33
+ canon-register --name "My Claude" --description "Claude Code agent" --phone "+15551234567"
34
+ canon-register --name "Frontend" --description "React work" --phone "+15551234567" --profile frontend
35
+
36
+ After approval, start it with CANON_AGENT=<profile> canon-claude --cwd /path/to/project.`;
16
37
  export async function main() {
17
38
  setDefaultResultOrder('ipv4first');
18
39
  const { values } = parseArgs({
@@ -102,4 +123,7 @@ export async function main() {
102
123
  runCli(import.meta.url, main, (error) => {
103
124
  console.error('[canon-register] Fatal error:', error);
104
125
  process.exit(1);
126
+ }, {
127
+ name: 'canon-register',
128
+ help: HELP,
105
129
  });
package/dist/server.js CHANGED
@@ -13,6 +13,17 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextpro
13
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
+ const HELP = `canon-channel-server — Claude Code MCP channel server for Canon
17
+
18
+ USAGE
19
+ canon-channel-server
20
+
21
+ FLAGS
22
+ --help, -h Show this help
23
+ --version, -V Show package version
24
+
25
+ This command is normally launched by Claude Code as an MCP stdio server. For
26
+ phone-controlled local sessions, use canon-claude instead.`;
16
27
  // ── MCP server ─────────────────────────────────────────────────────────
17
28
  const server = new Server({ name: 'canon-channel', version: '0.1.0' }, {
18
29
  capabilities: {
@@ -672,4 +683,7 @@ export async function main() {
672
683
  runCli(import.meta.url, main, (err) => {
673
684
  console.error('[canon] Fatal error:', err);
674
685
  process.exit(1);
686
+ }, {
687
+ name: 'canon-channel-server',
688
+ help: HELP,
675
689
  });
@@ -0,0 +1,44 @@
1
+ import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { DeliveryIntent, ModelOption } from '@canonmsg/core';
3
+ export type ClaudeInputKind = 'seed' | 'canon';
4
+ export interface ClaudeInputEnvelope {
5
+ kind: ClaudeInputKind;
6
+ msg: SDKUserMessage;
7
+ intent: DeliveryIntent;
8
+ sourceMessageId: string | null;
9
+ markAccepted: boolean;
10
+ turnKey: string;
11
+ }
12
+ export interface ClaudeRuntimeControlError {
13
+ value: string;
14
+ message: string;
15
+ updatedAt: number;
16
+ }
17
+ export interface ClaudePendingFinalDelivery {
18
+ turnKey: string;
19
+ text: string;
20
+ messageId: string;
21
+ }
22
+ export declare function createClaudeInputEnvelope(input: {
23
+ kind: ClaudeInputKind;
24
+ msg: SDKUserMessage;
25
+ intent?: DeliveryIntent;
26
+ sourceMessageId?: string | null;
27
+ markAccepted?: boolean;
28
+ }): ClaudeInputEnvelope;
29
+ export declare function buildClaudeFinalMessageId(input: {
30
+ agentId: string;
31
+ conversationId: string;
32
+ turnKey: string;
33
+ }): string;
34
+ export declare function shouldDeliverClaudeFinal(input: {
35
+ turn: ClaudeInputEnvelope | null;
36
+ finalizedTurnKeys: ReadonlySet<string>;
37
+ interruptedTurnKeys: ReadonlySet<string>;
38
+ }): boolean;
39
+ export declare function mergeClaudeModelOptions(input: {
40
+ previous: ReadonlyArray<ModelOption>;
41
+ next: ReadonlyArray<ModelOption>;
42
+ selectedModel?: string | null;
43
+ }): ModelOption[];
44
+ export declare function formatClaudeControlError(error: unknown): string;
@@ -0,0 +1,67 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ export function createClaudeInputEnvelope(input) {
3
+ const sourceMessageId = input.sourceMessageId ?? null;
4
+ const fallbackId = randomUUID();
5
+ return {
6
+ kind: input.kind,
7
+ msg: input.msg,
8
+ intent: input.intent ?? 'queue',
9
+ sourceMessageId,
10
+ markAccepted: Boolean(input.markAccepted),
11
+ turnKey: input.kind === 'canon' && sourceMessageId
12
+ ? `canon:${sourceMessageId}`
13
+ : `${input.kind}:${fallbackId}`,
14
+ };
15
+ }
16
+ export function buildClaudeFinalMessageId(input) {
17
+ const digest = createHash('sha256')
18
+ .update(`${input.agentId}\n${input.conversationId}\n${input.turnKey}`)
19
+ .digest('hex')
20
+ .slice(0, 32);
21
+ return `claude-final-${digest}`;
22
+ }
23
+ export function shouldDeliverClaudeFinal(input) {
24
+ if (!input.turn || input.turn.kind !== 'canon')
25
+ return false;
26
+ if (input.finalizedTurnKeys.has(input.turn.turnKey))
27
+ return false;
28
+ if (input.interruptedTurnKeys.has(input.turn.turnKey))
29
+ return false;
30
+ return true;
31
+ }
32
+ export function mergeClaudeModelOptions(input) {
33
+ const byValue = new Map();
34
+ for (const option of input.previous) {
35
+ byValue.set(option.value, { ...option });
36
+ }
37
+ for (const option of input.next) {
38
+ byValue.set(option.value, { ...option });
39
+ }
40
+ if (input.selectedModel && !byValue.has(input.selectedModel)) {
41
+ byValue.set(input.selectedModel, {
42
+ value: input.selectedModel,
43
+ label: input.selectedModel,
44
+ description: 'Selected for this Canon session but not returned by the latest Claude model probe.',
45
+ });
46
+ }
47
+ const nextValues = new Set(input.next.map((option) => option.value));
48
+ return [...byValue.values()].map((option) => {
49
+ if (nextValues.has(option.value))
50
+ return option;
51
+ return {
52
+ ...option,
53
+ description: option.description
54
+ ? `${option.description} Previously advertised; not returned by the latest Claude model probe.`
55
+ : 'Previously advertised; not returned by the latest Claude model probe.',
56
+ };
57
+ });
58
+ }
59
+ export function formatClaudeControlError(error) {
60
+ if (error instanceof Error && error.message.trim()) {
61
+ return error.message.trim();
62
+ }
63
+ if (typeof error === 'string' && error.trim()) {
64
+ return error.trim();
65
+ }
66
+ return 'Claude Code did not apply that control.';
67
+ }
package/dist/setup.js CHANGED
@@ -10,6 +10,17 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
10
10
  import { join, dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { runCli } from './cli-entry.js';
13
+ const HELP = `canon-setup — install Claude Code Canon skills and print MCP setup
14
+
15
+ USAGE
16
+ canon-setup
17
+
18
+ FLAGS
19
+ --help, -h Show this help
20
+ --version, -V Show package version
21
+
22
+ This command writes Canon Claude Code skill files under ~/.claude/skills and
23
+ prints the MCP server configuration for Claude Code channel mode.`;
13
24
  const __dirname = dirname(fileURLToPath(import.meta.url));
14
25
  const home = process.env.HOME || process.env.USERPROFILE || '';
15
26
  const skillsDir = join(home, '.claude', 'skills');
@@ -70,4 +81,7 @@ export function main() {
70
81
  runCli(import.meta.url, main, (error) => {
71
82
  console.error('[canon-setup] Fatal error:', error);
72
83
  process.exit(1);
84
+ }, {
85
+ name: 'canon-setup',
86
+ help: HELP,
73
87
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/claude-code-plugin",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
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",
@@ -32,7 +32,7 @@
32
32
  "dependencies": {
33
33
  "@anthropic-ai/claude-agent-sdk": "^0.2.101",
34
34
  "@canonmsg/agent-sdk": "^1.1.0",
35
- "@canonmsg/core": "^0.15.0",
35
+ "@canonmsg/core": "^0.15.2",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {