@commonlyai/cli 0.1.233 → 0.1.234

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.233",
3
+ "version": "0.1.234",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -134,6 +134,7 @@ import {
134
134
  peerHoldsFrame,
135
135
  resolveCascadeSettings,
136
136
  } from '../lib/enforcement.js';
137
+ import { buildReplyReceipt } from '../lib/reply-receipt.js';
137
138
 
138
139
  const isPodFocusDiagnostic = (error) => (
139
140
  typeof error?.code === 'string'
@@ -2296,6 +2297,8 @@ export const performRun = ({
2296
2297
  ? { mounted: serverSkills.mounted, conflicted: serverSkills.conflicted, ...(skillReadUnavailable ? { status: 'unavailable' } : {}) }
2297
2298
  : null;
2298
2299
  let spawnCompleted = false;
2300
+ // D245 item 4: how long the runtime ran, for the reply receipt.
2301
+ let spawnDurationMs;
2299
2302
  let observedSpawn = false;
2300
2303
  let commitAttribution = null;
2301
2304
  try {
@@ -2364,7 +2367,9 @@ export const performRun = ({
2364
2367
  };
2365
2368
  // Supervised or plain, a failure is thrown exactly as the adapter's own
2366
2369
  // would be, so the run loop's classification and retry apply unchanged.
2370
+ const spawnStartedAt = Date.now();
2367
2371
  result = await turn.run(adapter, spawnPrompt, spawnCtx);
2372
+ spawnDurationMs = Date.now() - spawnStartedAt;
2368
2373
  spawnCompleted = true;
2369
2374
  } finally {
2370
2375
  try {
@@ -2551,6 +2556,12 @@ export const performRun = ({
2551
2556
  maxChunks: maxChatChunks,
2552
2557
  uploadName: `${agentName}-reply-${event._id}.md`,
2553
2558
  log: (line) => log(`[${event.type}] ${line}`),
2559
+ // D245 item 4: the wrapper's own account of this turn.
2560
+ receipt: buildReplyReceipt({
2561
+ adapterName: adapter.name,
2562
+ stats: result.stats,
2563
+ durationMs: spawnDurationMs,
2564
+ }),
2554
2565
  });
2555
2566
  } catch (deliveryError) {
2556
2567
  // TASK-535: every rung threw, so the reply reached nobody. The throw
@@ -53,6 +53,7 @@ import { describeTurnReap, markTurn, turnReapEnabled } from '@commonlyai/seats/t
53
53
  */
54
54
 
55
55
  import { requireSeatModel, seatModelRequired } from './seat-model.js';
56
+ import { claudeRunStats } from '../reply-receipt.js';
56
57
  import { spawn as childSpawn, spawnSync } from 'child_process';
57
58
  import { createHash, randomUUID } from 'crypto';
58
59
  import { accessSync, constants, realpathSync } from 'fs';
@@ -1521,6 +1522,8 @@ export default {
1521
1522
  text: turnReply(stdout, { credentials: spawnCredentials(spawnCtx), publicMcp: witnessedMcp }),
1522
1523
  newSessionId: sessionId,
1523
1524
  contextTokens,
1525
+ // D245 item 4: what the run said about itself, for the reply receipt.
1526
+ stats: claudeRunStats(stdout),
1524
1527
  };
1525
1528
  } catch (err) {
1526
1529
  // A public seat's refusal is not a session-store failure, whatever its
@@ -1591,6 +1594,7 @@ export default {
1591
1594
  text: turnReply(stdout, { credentials: spawnCredentials(retryCtx), publicMcp: witnessedMcp }),
1592
1595
  newSessionId: freshId,
1593
1596
  contextTokens,
1597
+ stats: claudeRunStats(stdout),
1594
1598
  };
1595
1599
  }
1596
1600
  throw err;
@@ -668,6 +668,9 @@ export const deliverChatReply = async ({
668
668
  attachThreshold = 800,
669
669
  uploadName = 'reply.md',
670
670
  log = () => {},
671
+ // D245 item 4: how this reply was made (lib/reply-receipt.js). It rides
672
+ // the first message that actually reaches the pod and no later one.
673
+ receipt = null,
671
674
  }) => {
672
675
  const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
673
676
  const chunks = splitForChat(text, { limit });
@@ -697,7 +700,14 @@ export const deliverChatReply = async ({
697
700
  // override per delivery; a post that still fails throws to the next rung as
698
701
  // before. Nothing here loops.
699
702
  let freshnessOverride = false;
700
- const postMessage = async (body) => {
703
+ let receiptDelivered = !receipt;
704
+ const postOnce = async (rawBody) => {
705
+ const body = receiptDelivered ? rawBody : { ...rawBody, receipt };
706
+ const response = await postGuarded(body);
707
+ if (response?.refused !== true && response?.skipped !== true) receiptDelivered = true;
708
+ return response;
709
+ };
710
+ const postGuarded = async (body) => {
701
711
  if (freshnessOverride) return client.post(messagesPath, { ...body, continueAnyway: true });
702
712
  try {
703
713
  return await client.post(messagesPath, body);
@@ -774,7 +784,7 @@ export const deliverChatReply = async ({
774
784
  // the gate (found by the fleet's implementation audit).
775
785
  const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
776
786
  if (chunks.length <= 1 && !hasIndivisibleOversize) {
777
- const response = await postMessage({ content: chunks[0] ?? text });
787
+ const response = await postOnce({ content: chunks[0] ?? text });
778
788
  if (undelivered(response)) return nonDelivery(response, 0, 1);
779
789
  return { mode: 'single', messages: 1 };
780
790
  }
@@ -782,7 +792,7 @@ export const deliverChatReply = async ({
782
792
  let messages = 0;
783
793
  for (const chunk of chunks) {
784
794
  // eslint-disable-next-line no-await-in-loop
785
- const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
795
+ const response = await postOnce({ content: chunk }); // in order, so the reply reads top-down
786
796
  if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
787
797
  messages += 1;
788
798
  }
@@ -802,7 +812,7 @@ export const deliverChatReply = async ({
802
812
  // fail at the root-id step before any continuation has posted.
803
813
  let posted = 0;
804
814
  try {
805
- const rootRes = await postMessage({ content: chunks[0] });
815
+ const rootRes = await postOnce({ content: chunks[0] });
806
816
  if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
807
817
  posted = 1;
808
818
  // The runtime route answers `res.json(result)` with the created row on
@@ -814,7 +824,7 @@ export const deliverChatReply = async ({
814
824
  if (!rootId) throw new Error('no message id in post response — cannot root the thread');
815
825
  for (const chunk of chunks.slice(1)) {
816
826
  // eslint-disable-next-line no-await-in-loop
817
- const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
827
+ const response = await postOnce({ content: chunk, threadRootId: String(rootId) });
818
828
  if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
819
829
  posted += 1;
820
830
  }
@@ -829,7 +839,7 @@ export const deliverChatReply = async ({
829
839
  log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
830
840
  for (const chunk of chunks.slice(posted)) {
831
841
  // eslint-disable-next-line no-await-in-loop
832
- const response = await postMessage({ content: chunk });
842
+ const response = await postOnce({ content: chunk });
833
843
  if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
834
844
  posted += 1;
835
845
  }
@@ -855,7 +865,7 @@ export const deliverChatReply = async ({
855
865
  const lead = chunks[0] && chunks[0].length <= limit
856
866
  ? chunks[0]
857
867
  : '(reply too large for chat — attached in full)';
858
- const response = await postMessage({ content: `${lead}\n\n${directive}` });
868
+ const response = await postOnce({ content: `${lead}\n\n${directive}` });
859
869
  if (undelivered(response)) return nonDelivery(response, 0, 1);
860
870
  return { mode: 'attach', messages: 1 };
861
871
  } catch (err) {
@@ -863,7 +873,7 @@ export const deliverChatReply = async ({
863
873
  let messages = 0;
864
874
  for (const chunk of chunks) {
865
875
  // eslint-disable-next-line no-await-in-loop
866
- const response = await postMessage({ content: chunk });
876
+ const response = await postOnce({ content: chunk });
867
877
  if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
868
878
  messages += 1;
869
879
  }
@@ -0,0 +1,53 @@
1
+ // D245 item 4: the receipt the wrapper posts with a reply, so a BYO reply
2
+ // carries "how this reply was made" the way a hosted reply has its run trace.
3
+ // It is the wrapper's own account and the server stores it as self-reported
4
+ // (backend/services/byoReceipt.ts), which also bounds and checks every field.
5
+ // This side keeps to what it actually observed: an adapter that reports no
6
+ // stats sends only its name and the measured duration.
7
+
8
+ const isName = (value) => typeof value === 'string' && value.length > 0;
9
+ const isCount = (value) => Number.isInteger(value) && value >= 0;
10
+
11
+ const parseLine = (raw) => {
12
+ try { return JSON.parse(raw); } catch { return null; }
13
+ };
14
+
15
+ /**
16
+ * What a Claude Code stream-json run says about itself: the model from the
17
+ * init event, the turn count from the result event, and each tool it called,
18
+ * once, in first-use order.
19
+ */
20
+ export const claudeRunStats = (stdout) => {
21
+ const events = String(stdout || '').split('\n').map(parseLine).filter(Boolean);
22
+ const stats = {};
23
+ const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
24
+ if (isName(init?.model)) stats.model = init.model;
25
+ const result = [...events].reverse().find((event) => event.type === 'result');
26
+ if (isCount(result?.num_turns)) stats.turns = result.num_turns;
27
+ const tools = [];
28
+ for (const event of events) {
29
+ if (event.type !== 'assistant') continue;
30
+ for (const block of event.message?.content || []) {
31
+ if (block?.type === 'tool_use' && isName(block.name) && !tools.includes(block.name)) tools.push(block.name);
32
+ }
33
+ }
34
+ if (tools.length) stats.tools = tools;
35
+ return stats;
36
+ };
37
+
38
+ /** The receipt for one reply, or null when there is no adapter to name. */
39
+ export const buildReplyReceipt = ({ adapterName, stats = {}, durationMs } = {}) => {
40
+ if (!isName(adapterName)) return null;
41
+ const receipt = { adapter: adapterName };
42
+ // A cloud-hosted model can be named by an ARN or a resource path that
43
+ // carries the account or project it runs in; that is not this reply's to
44
+ // publish, so such a model id is left out.
45
+ if (isName(stats?.model) && !/^arn:|(^|\/)projects\//i.test(stats.model)) receipt.model = stats.model;
46
+ if (isCount(stats?.turns)) receipt.turns = stats.turns;
47
+ if (Array.isArray(stats?.tools)) {
48
+ const tools = stats.tools.filter(isName);
49
+ if (tools.length) receipt.tools = tools;
50
+ }
51
+ if (Number.isFinite(durationMs) && durationMs >= 0) receipt.durationMs = Math.round(durationMs);
52
+ return receipt;
53
+ };