@canonmsg/claude-code-plugin 0.12.4 → 0.12.5

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 = [];
@@ -465,12 +487,40 @@ const CLAUDE_RUNTIME_CAPABILITIES = {
465
487
  supportsNonFinalPermanentMessages: false,
466
488
  };
467
489
  function buildCanonPrompt(input) {
490
+ const { session, ...promptInput } = input;
468
491
  return buildCanonHostPrompt({
469
492
  hostLabel: 'Claude Code',
470
493
  buildInboundContextLines,
471
- ...input,
494
+ sessionContextLines: session ? buildClaudeSessionContextLines(session) : undefined,
495
+ ...promptInput,
472
496
  });
473
497
  }
498
+ function optionLabel(value, options) {
499
+ if (!value)
500
+ return null;
501
+ const label = options.find((option) => option.value === value)?.label ?? value;
502
+ return label === value ? value : `${label} (${value})`;
503
+ }
504
+ function buildClaudeSessionContextLines(session) {
505
+ const workspace = workspaceOptions.find((option) => option.cwd === session.environment.baseCwd);
506
+ const permissionMode = optionLabel(session.state.permissionMode, CLAUDE_PERMISSION_MODE_OPTIONS);
507
+ const effort = optionLabel(session.state.effort, [
508
+ { value: 'low', label: 'Low' },
509
+ { value: 'medium', label: 'Medium' },
510
+ { value: 'high', label: 'High' },
511
+ ]);
512
+ const model = optionLabel(session.state.model, session.availableModels);
513
+ return [
514
+ model ? `Selected model: ${model}` : null,
515
+ permissionMode ? `Permission mode: ${permissionMode}` : null,
516
+ effort ? `Thinking level: ${effort}` : null,
517
+ workspace ? `Project: ${workspace.label} (${workspace.id})` : null,
518
+ `Execution mode: ${session.environment.mode}`,
519
+ `Working directory: ${session.cwd}`,
520
+ session.environment.branch ? `Git branch: ${session.environment.branch}` : null,
521
+ session.environment.worktreePath ? `Worktree: ${session.environment.worktreePath}` : null,
522
+ ].filter((line) => Boolean(line));
523
+ }
474
524
  function renderInboundContent(message, materialized) {
475
525
  return renderCanonHostInboundContent(message, materialized);
476
526
  }
@@ -501,19 +551,25 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
501
551
  let resolveInput = null;
502
552
  let inputDone = false;
503
553
  const messageQueue = [];
504
- const sendInput = (msg) => {
554
+ const sendInput = (input) => {
505
555
  if (resolveInput) {
506
556
  const r = resolveInput;
507
557
  resolveInput = null;
508
- r(msg);
558
+ r(input);
509
559
  }
510
560
  else {
511
561
  // Queue if iterator isn't waiting yet (e.g., during init)
512
- messageQueue.push(msg);
562
+ messageQueue.push(input);
513
563
  }
514
564
  };
515
- let enqueueInboundMessage = (msg, _intent = 'queue', _sourceMessageId, _markAccepted = false) => {
516
- sendInput(msg);
565
+ let enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId, markAccepted = false) => {
566
+ sendInput(createClaudeInputEnvelope({
567
+ kind: 'canon',
568
+ msg,
569
+ intent,
570
+ sourceMessageId,
571
+ markAccepted,
572
+ }));
517
573
  };
518
574
  const inputStream = {
519
575
  [Symbol.asyncIterator]() {
@@ -523,10 +579,15 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
523
579
  return Promise.resolve({ done: true, value: undefined });
524
580
  // Drain queued messages first (sent before iterator was ready)
525
581
  if (messageQueue.length > 0) {
526
- return Promise.resolve({ done: false, value: messageQueue.shift() });
582
+ const next = messageQueue.shift();
583
+ session.activeInput = next;
584
+ return Promise.resolve({ done: false, value: next.msg });
527
585
  }
528
586
  return new Promise((resolve) => {
529
- resolveInput = (msg) => resolve({ done: false, value: msg });
587
+ resolveInput = (input) => {
588
+ session.activeInput = input;
589
+ resolve({ done: false, value: input.msg });
590
+ };
530
591
  });
531
592
  },
532
593
  return() {
@@ -669,6 +730,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
669
730
  state: {
670
731
  model: config.model,
671
732
  permissionMode: config.permissionMode,
733
+ effort: config.effort,
672
734
  state: 'idle',
673
735
  },
674
736
  availableModels: config.availableModels,
@@ -678,8 +740,12 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
678
740
  finalDeliveryTimer: null,
679
741
  seedResponseHandled: !!resumeSessionId, // Resumed sessions don't need seed handling
680
742
  pendingInputs: [],
743
+ activeInput: null,
744
+ finalizedTurnKeys: new Set(),
745
+ interruptedTurnKeys: new Set(),
681
746
  pendingFinalText: null,
682
- pendingFinalDeliveryText: null,
747
+ pendingFinalDelivery: null,
748
+ runtimeControlErrors: {},
683
749
  toolInProgress: false,
684
750
  turnState: 'idle',
685
751
  currentTurnId: null,
@@ -690,6 +756,10 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
690
756
  closed: false,
691
757
  sdkSessionId: resumeSessionId || null,
692
758
  pendingReply: null,
759
+ writeState: () => writeState(),
760
+ writeTurn: () => writeTurn(),
761
+ setRuntimeControlError: (controlId, value, error) => setRuntimeControlError(controlId, value, error),
762
+ clearRuntimeControlError: (controlId) => clearRuntimeControlError(controlId),
693
763
  };
694
764
  const streamingBuffer = createStreamingBuffer({
695
765
  throttleMs: STREAMING_THROTTLE_MS,
@@ -739,7 +809,8 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
739
809
  session.currentTurnOpenedAt = null;
740
810
  session.lastAcceptedIntent = null;
741
811
  session.pendingFinalText = null;
742
- session.pendingFinalDeliveryText = null;
812
+ session.pendingFinalDelivery = null;
813
+ session.activeInput = null;
743
814
  session.toolInProgress = false;
744
815
  session.turnState = 'idle';
745
816
  stopVisibleWorkSignal();
@@ -755,10 +826,18 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
755
826
  resetTurnToIdle();
756
827
  }, FINAL_MESSAGE_HANDOFF_MS);
757
828
  }
758
- function markPendingFinalDelivery(finalText) {
829
+ function markPendingFinalDelivery(finalText, turn) {
759
830
  clearIdleResetTimer();
760
831
  session.pendingFinalText = finalText;
761
- session.pendingFinalDeliveryText = finalText;
832
+ session.pendingFinalDelivery = {
833
+ turnKey: turn.turnKey,
834
+ text: finalText,
835
+ messageId: buildClaudeFinalMessageId({
836
+ agentId,
837
+ conversationId,
838
+ turnKey: turn.turnKey,
839
+ }),
840
+ };
762
841
  session.state.state = 'running';
763
842
  session.turnState = 'streaming';
764
843
  stopVisibleWorkSignal();
@@ -767,49 +846,75 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
767
846
  writeTurn();
768
847
  client.setTyping(conversationId, true, 'typing').catch(() => { });
769
848
  }
770
- async function deliverFinalReply(finalText) {
849
+ async function deliverFinalReply(finalText, turn = session.activeInput) {
850
+ if (!shouldDeliverClaudeFinal({
851
+ turn,
852
+ finalizedTurnKeys: session.finalizedTurnKeys,
853
+ interruptedTurnKeys: session.interruptedTurnKeys,
854
+ })) {
855
+ return true;
856
+ }
857
+ const deliverTurn = turn;
858
+ if (!deliverTurn)
859
+ return true;
860
+ const finalMessageId = buildClaudeFinalMessageId({
861
+ agentId,
862
+ conversationId,
863
+ turnKey: deliverTurn.turnKey,
864
+ });
771
865
  try {
772
866
  await client.sendMessage(conversationId, finalText, {
867
+ messageId: finalMessageId,
773
868
  metadata: {
774
869
  turnId: session.currentTurnId,
870
+ turnKey: deliverTurn.turnKey,
871
+ sourceMessageId: deliverTurn.sourceMessageId ?? undefined,
775
872
  turnSemantics: 'turn_complete',
776
873
  turnComplete: true,
777
874
  deliveryIntent: session.lastAcceptedIntent ?? undefined,
778
875
  },
779
876
  });
877
+ session.finalizedTurnKeys.add(deliverTurn.turnKey);
780
878
  session.state.state = 'idle';
781
- session.pendingFinalDeliveryText = null;
879
+ session.pendingFinalDelivery = null;
782
880
  clearFinalDeliveryTimer();
783
881
  writeState();
784
882
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Sent final reply (${finalText.length} chars)`);
785
883
  return true;
786
884
  }
787
885
  catch (err) {
788
- markPendingFinalDelivery(finalText);
886
+ markPendingFinalDelivery(finalText, deliverTurn);
789
887
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Failed to send final reply:`, err);
790
888
  return false;
791
889
  }
792
890
  }
793
- function scheduleFinalDeliveryRetry(finalText) {
794
- if (session.closed || session.pendingFinalDeliveryText !== finalText)
891
+ function scheduleFinalDeliveryRetry(pending = session.pendingFinalDelivery) {
892
+ if (!pending || session.closed || session.pendingFinalDelivery?.turnKey !== pending.turnKey)
795
893
  return;
796
894
  clearFinalDeliveryTimer();
797
895
  session.finalDeliveryTimer = setTimeout(() => {
798
896
  session.finalDeliveryTimer = null;
799
- void deliverFinalReply(finalText).then((sent) => {
897
+ const retryTurn = session.activeInput?.turnKey === pending.turnKey
898
+ ? session.activeInput
899
+ : null;
900
+ void deliverFinalReply(pending.text, retryTurn).then((sent) => {
800
901
  if (sent) {
801
902
  scheduleFinalHandoffReset();
802
903
  return;
803
904
  }
804
- scheduleFinalDeliveryRetry(finalText);
905
+ scheduleFinalDeliveryRetry();
805
906
  });
806
907
  }, FINAL_DELIVERY_RETRY_MS);
807
908
  }
808
909
  function writeState() {
910
+ const runtimeControlErrors = Object.keys(session.runtimeControlErrors).length > 0
911
+ ? session.runtimeControlErrors
912
+ : null;
809
913
  runtimeState.writeSessionState(conversationId, {
810
914
  model: session.state.model,
811
915
  permissionMode: session.state.permissionMode,
812
916
  effort: session.state.effort,
917
+ runtimeControlErrors,
813
918
  contextUsage: session.state.contextUsage,
814
919
  ...(session.availableModels.length ? { availableModels: session.availableModels } : {}),
815
920
  cwd,
@@ -823,6 +928,25 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
823
928
  isActive: true,
824
929
  }).catch(() => { });
825
930
  }
931
+ function setRuntimeControlError(controlId, value, error) {
932
+ session.runtimeControlErrors = {
933
+ ...session.runtimeControlErrors,
934
+ [controlId]: {
935
+ value,
936
+ message: formatClaudeControlError(error),
937
+ updatedAt: Date.now(),
938
+ },
939
+ };
940
+ writeState();
941
+ }
942
+ function clearRuntimeControlError(controlId) {
943
+ if (!(controlId in session.runtimeControlErrors))
944
+ return;
945
+ const next = { ...session.runtimeControlErrors };
946
+ delete next[controlId];
947
+ session.runtimeControlErrors = next;
948
+ writeState();
949
+ }
826
950
  function writeTurn() {
827
951
  runtimeState.writeTurnState(conversationId, {
828
952
  turnId: session.currentTurnId,
@@ -870,22 +994,32 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
870
994
  session.lastAcceptedIntent = next.intent;
871
995
  writeTurn();
872
996
  void markQueuedMessageAccepted(next.sourceMessageId, next.markAccepted)
873
- .finally(() => sendInput(next.msg));
997
+ .finally(() => sendInput(next));
874
998
  }
875
999
  enqueueInboundMessage = (msg, intent = 'queue', sourceMessageId = null, markAccepted = false) => {
876
1000
  session.lastActivity = Date.now();
877
- if (session.pendingFinalDeliveryText) {
878
- session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
1001
+ const input = createClaudeInputEnvelope({
1002
+ kind: 'canon',
1003
+ msg,
1004
+ intent,
1005
+ sourceMessageId,
1006
+ markAccepted,
1007
+ });
1008
+ if (session.pendingFinalDelivery) {
1009
+ session.pendingInputs.push(input);
879
1010
  writeTurn();
880
1011
  return;
881
1012
  }
882
1013
  if (session.state.state === 'running' && intent !== 'interrupt') {
883
- session.pendingInputs.push({ msg, intent, sourceMessageId, markAccepted });
1014
+ session.pendingInputs.push(input);
884
1015
  writeTurn();
885
1016
  return;
886
1017
  }
887
1018
  if (session.state.state === 'running' && intent === 'interrupt') {
888
- session.pendingInputs.unshift({ msg, intent, sourceMessageId, markAccepted });
1019
+ if (session.activeInput) {
1020
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
1021
+ }
1022
+ session.pendingInputs.unshift(input);
889
1023
  session.turnState = 'interrupted';
890
1024
  stopVisibleWorkSignal();
891
1025
  writeTurn();
@@ -895,7 +1029,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
895
1029
  session.lastAcceptedIntent = intent;
896
1030
  writeTurn();
897
1031
  void markQueuedMessageAccepted(sourceMessageId, markAccepted)
898
- .finally(() => sendInput(msg));
1032
+ .finally(() => sendInput(input));
899
1033
  };
900
1034
  writeTurn();
901
1035
  // ── Process SDK output messages ──
@@ -991,12 +1125,22 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
991
1125
  const resultSessionId = msg.session_id;
992
1126
  if (resultSessionId)
993
1127
  session.sdkSessionId = resultSessionId;
1128
+ const completedInput = session.activeInput;
994
1129
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Turn complete (${msg.subtype})`);
995
1130
  const resultText = typeof msg.result === 'string'
996
1131
  ? msg.result.trim()
997
1132
  : '';
998
1133
  const finalText = resultText || session.pendingFinalText?.trim() || null;
999
- const finalDelivered = finalText ? await deliverFinalReply(finalText) : true;
1134
+ const shouldDeliverFinal = finalText
1135
+ ? shouldDeliverClaudeFinal({
1136
+ turn: completedInput,
1137
+ finalizedTurnKeys: session.finalizedTurnKeys,
1138
+ interruptedTurnKeys: session.interruptedTurnKeys,
1139
+ })
1140
+ : false;
1141
+ const finalDelivered = finalText && shouldDeliverFinal
1142
+ ? await deliverFinalReply(finalText, completedInput)
1143
+ : true;
1000
1144
  try {
1001
1145
  const usage = await q.getContextUsage();
1002
1146
  session.state.contextUsage = {
@@ -1011,10 +1155,19 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1011
1155
  // Non-fatal
1012
1156
  }
1013
1157
  if (finalDelivered) {
1014
- scheduleFinalHandoffReset();
1158
+ if (completedInput?.turnKey === session.activeInput?.turnKey) {
1159
+ session.activeInput = null;
1160
+ }
1161
+ session.pendingFinalText = null;
1162
+ if (shouldDeliverFinal && finalText) {
1163
+ scheduleFinalHandoffReset();
1164
+ }
1165
+ else {
1166
+ resetTurnToIdle();
1167
+ }
1015
1168
  }
1016
1169
  else if (finalText) {
1017
- scheduleFinalDeliveryRetry(finalText);
1170
+ scheduleFinalDeliveryRetry();
1018
1171
  }
1019
1172
  break;
1020
1173
  }
@@ -1056,19 +1209,26 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1056
1209
  }
1057
1210
  if (!resumeSessionId) {
1058
1211
  // 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).',
1212
+ sendInput(createClaudeInputEnvelope({
1213
+ kind: 'seed',
1214
+ msg: {
1215
+ type: 'user',
1216
+ message: {
1217
+ role: 'user',
1218
+ 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).',
1219
+ },
1220
+ parent_tool_use_id: null,
1064
1221
  },
1065
- parent_tool_use_id: null,
1066
- });
1222
+ }));
1067
1223
  }
1068
1224
  // Refresh the canonical runtime descriptor after Claude reports supported models.
1069
1225
  try {
1070
1226
  const models = await q.supportedModels();
1071
- const modelList = toModelOptions(models);
1227
+ const modelList = mergeClaudeModelOptions({
1228
+ previous: runtimeModels,
1229
+ next: toModelOptions(models),
1230
+ selectedModel: session.state.model,
1231
+ });
1072
1232
  session.availableModels = modelList;
1073
1233
  runtimeModels = modelList;
1074
1234
  onRuntimeDescriptorUpdate({
@@ -1085,21 +1245,7 @@ function createSession(conversationId, environment, agentId, client, runtimeStat
1085
1245
  executionModes: config.availableExecutionModes,
1086
1246
  }),
1087
1247
  });
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(() => { });
1248
+ writeState();
1103
1249
  console.error(`[canon-host] [${conversationId.slice(0, 8)}] Models: ${models.map((m) => m.displayName).join(', ')}`);
1104
1250
  }
1105
1251
  catch {
@@ -1513,7 +1659,10 @@ export async function main() {
1513
1659
  availableExecutionModes: hostAvailableExecutionModes,
1514
1660
  }, resumeId);
1515
1661
  sessions.set(conversationId, session);
1516
- await baselineControlSignal(conversationId);
1662
+ await Promise.all([
1663
+ baselineControlSignal(conversationId),
1664
+ baselineSessionControl(conversationId),
1665
+ ]);
1517
1666
  return session;
1518
1667
  }
1519
1668
  catch (error) {
@@ -1540,6 +1689,17 @@ export async function main() {
1540
1689
  lastSeenSignal.set(conversationId, timestamp);
1541
1690
  }
1542
1691
  }
1692
+ async function baselineSessionControl(conversationId) {
1693
+ if (lastSeenControl.has(conversationId))
1694
+ return;
1695
+ const raw = await rtdbRead(`/control/${conversationId}/${agentId}/session`).catch(() => null);
1696
+ if (!raw || typeof raw !== 'object')
1697
+ return;
1698
+ const timestamp = Number(raw.updatedAt ?? 0);
1699
+ if (timestamp > 0) {
1700
+ lastSeenControl.set(conversationId, timestamp);
1701
+ }
1702
+ }
1543
1703
  async function enqueueRecoveredInboundMessage(input) {
1544
1704
  const m = input.message;
1545
1705
  if (m.senderId === agentId)
@@ -1599,6 +1759,7 @@ export async function main() {
1599
1759
  behavior,
1600
1760
  workSession: m.workSession,
1601
1761
  workSessions,
1762
+ session,
1602
1763
  });
1603
1764
  const messageContent = await buildCanonUserContent({ promptText, materialized });
1604
1765
  session.enqueueInbound({
@@ -1747,6 +1908,7 @@ export async function main() {
1747
1908
  behavior,
1748
1909
  workSession: m.workSession,
1749
1910
  workSessions,
1911
+ session,
1750
1912
  });
1751
1913
  const messageContent = await buildCanonUserContent({ promptText, materialized });
1752
1914
  session.enqueueInbound({
@@ -1801,9 +1963,11 @@ export async function main() {
1801
1963
  try {
1802
1964
  await session.query.setModel(ctrl.model);
1803
1965
  session.state.model = ctrl.model;
1966
+ session.clearRuntimeControlError('model');
1804
1967
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Model → ${ctrl.model}`);
1805
1968
  }
1806
1969
  catch (err) {
1970
+ session.setRuntimeControlError('model', ctrl.model, err);
1807
1971
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set model:`, err);
1808
1972
  }
1809
1973
  }
@@ -1811,9 +1975,11 @@ export async function main() {
1811
1975
  try {
1812
1976
  await session.query.setPermissionMode(ctrl.permissionMode);
1813
1977
  session.state.permissionMode = ctrl.permissionMode;
1978
+ session.clearRuntimeControlError('permissionMode');
1814
1979
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Mode → ${ctrl.permissionMode}`);
1815
1980
  }
1816
1981
  catch (err) {
1982
+ session.setRuntimeControlError('permissionMode', ctrl.permissionMode, err);
1817
1983
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set mode:`, err);
1818
1984
  }
1819
1985
  }
@@ -1822,27 +1988,19 @@ export async function main() {
1822
1988
  try {
1823
1989
  await session.query.applyFlagSettings({ effortLevel: nextEffort });
1824
1990
  session.state.effort = nextEffort;
1991
+ session.clearRuntimeControlError('effort');
1825
1992
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Effort → ${nextEffort}`);
1826
1993
  }
1827
1994
  catch (err) {
1995
+ session.setRuntimeControlError('effort', nextEffort, err);
1828
1996
  console.error(`[canon-host] [${convoId.slice(0, 8)}] Failed to set effort:`, err);
1829
1997
  }
1830
1998
  }
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(() => { });
1999
+ else if (ctrl.effort && !nextEffort) {
2000
+ session.setRuntimeControlError('effort', String(ctrl.effort), new Error('Unsupported Claude thinking level.'));
2001
+ }
2002
+ session.writeState();
2003
+ await rtdbWrite(`/control/${convoId}/${agentId}/session`, null).catch(() => { });
1846
2004
  }
1847
2005
  }
1848
2006
  }
@@ -1866,6 +2024,10 @@ export async function main() {
1866
2024
  const droppedInputs = session.pendingInputs.splice(0);
1867
2025
  await markSessionPendingInputsRejected(convoId, droppedInputs);
1868
2026
  }
2027
+ if (session.activeInput) {
2028
+ session.interruptedTurnKeys.add(session.activeInput.turnKey);
2029
+ }
2030
+ session.pendingFinalText = null;
1869
2031
  session.turnState = 'interrupted';
1870
2032
  runtimeState.writeTurnState(convoId, {
1871
2033
  turnId: session.currentTurnId,
@@ -1907,30 +2069,8 @@ export async function main() {
1907
2069
  // ── Heartbeat (per-session) ──
1908
2070
  const heartbeat = setInterval(() => {
1909
2071
  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(() => { });
2072
+ session.writeState();
2073
+ session.writeTurn();
1934
2074
  }
1935
2075
  void publishRuntimeHeartbeat();
1936
2076
  }, HEARTBEAT_MS);
@@ -1971,7 +2111,11 @@ export async function main() {
1971
2111
  }
1972
2112
  // ── Entry ───────────────────────────────────────────────────────────
1973
2113
  runCli(import.meta.url, main, (err) => {
1974
- console.error('[canon-host] Fatal error:', err);
2114
+ const message = err instanceof Error ? err.message : String(err);
2115
+ console.error(`[canon-host] ${message}`);
1975
2116
  getActiveProfileLock()?.release();
1976
2117
  process.exit(1);
2118
+ }, {
2119
+ name: 'canon-claude',
2120
+ help: HELP,
1977
2121
  });
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.5",
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.1",
36
36
  "@modelcontextprotocol/sdk": "^1.12.0"
37
37
  },
38
38
  "engines": {