@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 +1 -1
- package/src/commands/agent.js +11 -0
- package/src/lib/adapters/claude.js +4 -0
- package/src/lib/enforcement.js +18 -8
- package/src/lib/reply-receipt.js +53 -0
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -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;
|
package/src/lib/enforcement.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|