@commonlyai/cli 0.1.15 → 0.1.23

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.15",
3
+ "version": "0.1.23",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -28,7 +28,8 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "start": "node src/index.js",
31
- "lint": "eslint src/",
31
+ "lint": "eslint src --ext .js",
32
+ "lint:fix": "eslint src --ext .js --fix",
32
33
  "test": "node --experimental-vm-modules node_modules/.bin/jest",
33
34
  "prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
34
35
  },
@@ -36,6 +37,7 @@
36
37
  "commander": "^12.0.0"
37
38
  },
38
39
  "devDependencies": {
40
+ "eslint": "^8.56.0",
39
41
  "jest": "^29.7.0"
40
42
  },
41
43
  "jest": {
@@ -29,6 +29,7 @@ import {
29
29
  recordHandledEvent,
30
30
  } from '../lib/session-store.js';
31
31
  import { readLongTerm, syncBack } from '../lib/memory-bridge.js';
32
+ import { pollRetryPolicy } from '../lib/poll-retry.js';
32
33
  import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js';
33
34
  import { detectSkills, importSkills } from '../lib/skills-import.js';
34
35
  import { parseEnvironmentFile, resolveWorkspace } from '../lib/environment.js';
@@ -350,6 +351,25 @@ export const assertSandboxDeclaredForPublicPod = async ({
350
351
  );
351
352
  };
352
353
 
354
+ // ── wake: toggle ADR-018 ambient wake on an attached agent ──────────────────
355
+
356
+ /**
357
+ * Set `config.wakeOnMessage.enabled` on an installation via the registry
358
+ * PATCH route (user JWT, pod member or creator). Pure core — takes the token
359
+ * record so the command doesn't need to ask for the pod again.
360
+ */
361
+ export const setWakeOnMessage = async ({ client, record, enabled }) => {
362
+ if (!record?.podId || !record?.agentName) {
363
+ throw new Error('token record is missing podId/agentName — re-attach the agent');
364
+ }
365
+ const instanceId = record.instanceId || 'default';
366
+ await client.patch(
367
+ `/api/registry/pods/${record.podId}/agents/${record.agentName}`,
368
+ { instanceId, config: { wakeOnMessage: { enabled: Boolean(enabled) } } },
369
+ );
370
+ return { agentName: record.agentName, podId: record.podId, instanceId, enabled: Boolean(enabled) };
371
+ };
372
+
353
373
  // ── attach: register a local-CLI-wrapped agent (ADR-005) ────────────────────
354
374
 
355
375
  /**
@@ -363,6 +383,7 @@ export const performAttach = async ({
363
383
  podId,
364
384
  displayName,
365
385
  envPath = null,
386
+ wakeOnMessage = false,
366
387
  log = () => {},
367
388
  }) => {
368
389
  const adapter = getAdapter(adapterName);
@@ -505,6 +526,10 @@ export const performAttach = async ({
505
526
  host: 'byo',
506
527
  },
507
528
  ...(environment ? { environment } : {}),
529
+ // ADR-018: ambient wake is a per-install opt-in read by
530
+ // agentMentionService (`config.wakeOnMessage.enabled === true`, default
531
+ // OFF). Until now the only way to set it for a BYO seat was a DB write.
532
+ ...(wakeOnMessage ? { wakeOnMessage: { enabled: true } } : {}),
508
533
  },
509
534
  scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'],
510
535
  });
@@ -732,6 +757,7 @@ export const performRun = ({
732
757
  // reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
733
758
  let consecutiveAuthErrors = 0;
734
759
  const MAX_AUTH_ERRORS = 3;
760
+ let consecutivePollFailures = 0;
735
761
  let consecutiveSpawnFailures = 0;
736
762
  const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
737
763
  // Per-seat cascade state — lives with the process, like the session store.
@@ -945,10 +971,20 @@ export const performRun = ({
945
971
  });
946
972
  const claim = await claimKeeper.acquire();
947
973
  if (!claim.claimed && !claim.failOpen) {
948
- if (ADDRESSED_EVENT_TYPES.has(event.type)) {
974
+ // Per-event EVIDENCE, deliberately not a widening of
975
+ // ADDRESSED_EVENT_TYPES (that set is a pricing table): the backend
976
+ // stamps repliesToYourMessage when the woken message replies to or
977
+ // threads on a message THIS seat authored. Without it, a peer's
978
+ // claim on its own reply ordered the replied-to author out of its
979
+ // own conversation (Sage stood down twice on Anvil's thread
980
+ // replies, 2026-08-24). Interim for TASK-058.
981
+ const repliesToThisSeat = event.payload?.repliesToYourMessage === true;
982
+ if (ADDRESSED_EVENT_TYPES.has(event.type) || repliesToThisSeat) {
949
983
  log(
950
984
  `[${event.type}] message ${claimMessageId} held by ${claim.holder} — `
951
- + 'proceeding peer-aware (this seat was directly addressed)',
985
+ + (repliesToThisSeat
986
+ ? 'proceeding peer-aware (the message replies to this seat\'s own message)'
987
+ : 'proceeding peer-aware (this seat was directly addressed)'),
952
988
  );
953
989
  peerFrame = peerHoldsFrame(claim.holder, claimMessageId);
954
990
  claimKeeper = null; // nothing held: no renewal, no release, no isLost gate
@@ -1057,6 +1093,7 @@ export const performRun = ({
1057
1093
  && /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
1058
1094
  const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
1059
1095
  let delivered = agentPostedItself;
1096
+ let deliveryRefusal = null;
1060
1097
 
1061
1098
  if (event.type === 'agent.ask') {
1062
1099
  if (silentReply) {
@@ -1142,11 +1179,32 @@ export const performRun = ({
1142
1179
  uploadName: `${agentName}-reply-${event._id}.md`,
1143
1180
  log: (line) => log(`[${event.type}] ${line}`),
1144
1181
  });
1145
- delivered = true;
1146
- log(
1147
- `[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as `
1148
- + `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`,
1149
- );
1182
+ if (delivery.refused) {
1183
+ // A run-cap refusal is a successful HTTP request but not a delivery.
1184
+ // Ack it so the kernel does not replay the same text (the server
1185
+ // guidance expressly says not to retry unchanged), while preserving
1186
+ // the refusal and its partial-post count for the seat and event ledger.
1187
+ deliveryRefusal = delivery;
1188
+ const guidance = delivery.guidance || 'The server refused this message; do not retry it unchanged.';
1189
+ const detail = `after ${delivery.messages}/${delivery.attemptedMessages} message${delivery.attemptedMessages === 1 ? '' : 's'}`;
1190
+ log(`[${event.type}] wrapper delivery refused ${detail} (${delivery.reason}): ${guidance}`);
1191
+ onError?.(Object.assign(
1192
+ new Error(`${event.type} wrapper delivery refused ${detail}: ${guidance}`),
1193
+ {
1194
+ code: 'agent_delivery_refused',
1195
+ reason: delivery.reason,
1196
+ eventId: event._id,
1197
+ postedMessages: delivery.messages,
1198
+ attemptedMessages: delivery.attemptedMessages,
1199
+ },
1200
+ ));
1201
+ } else {
1202
+ delivered = true;
1203
+ log(
1204
+ `[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as `
1205
+ + `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`,
1206
+ );
1207
+ }
1150
1208
  }
1151
1209
  if (result.memorySummary) {
1152
1210
  try {
@@ -1164,6 +1222,21 @@ export const performRun = ({
1164
1222
  // streak. Recording only on completion means a spawn failure that gets
1165
1223
  // redelivered never double-counts toward the cap.
1166
1224
  cascadeGovernor.record(eventPodId, trigger);
1225
+ if (deliveryRefusal) {
1226
+ return {
1227
+ outcome: 'no_action',
1228
+ reason: deliveryRefusal.reason,
1229
+ details: {
1230
+ mode: deliveryRefusal.mode,
1231
+ postedMessages: deliveryRefusal.messages,
1232
+ attemptedMessages: deliveryRefusal.attemptedMessages,
1233
+ ...(typeof deliveryRefusal.consecutive === 'number'
1234
+ ? { consecutive: deliveryRefusal.consecutive }
1235
+ : {}),
1236
+ ...(deliveryRefusal.guidance ? { guidance: deliveryRefusal.guidance } : {}),
1237
+ },
1238
+ };
1239
+ }
1167
1240
  return { outcome: delivered ? 'posted' : 'no_action' };
1168
1241
  };
1169
1242
 
@@ -1171,10 +1244,32 @@ export const performRun = ({
1171
1244
  if (!running) return;
1172
1245
  let nextPollDelayMs = intervalMs;
1173
1246
  try {
1247
+ // ONE event per fetch, because the fetch IS the claim.
1248
+ //
1249
+ // `AgentEventService.list()` does not hand back a preview — it marks
1250
+ // every candidate `delivered` with `$inc: { attempts: 1 }` before
1251
+ // returning. This loop then processes them SERIALLY, one full model
1252
+ // turn each. So asking for 10 claims 10 and starts 1.
1253
+ //
1254
+ // The nine it cannot start are then reclaimed out from under it: the
1255
+ // backend requeues `delivered` rows older than
1256
+ // `requeueDeliveredMinutes` (default 10, swept on `*/10`), and turns
1257
+ // routinely outlast that — measured on the pod-architect seat over
1258
+ // 11.5h: median 128s, p90 669s, 13 turns over 600s, max 1153s. Each
1259
+ // sweep returns the untouched siblings to `pending` at `attempts + 1`,
1260
+ // and `attempts >= 3` retires an event to `failed`, which is terminal
1261
+ // and invisible to `list()`. That cap exists to bound POISON events;
1262
+ // over-claiming feeds it work no model ever saw, so a mention can be
1263
+ // dropped without once being read.
1264
+ //
1265
+ // `limit: 1` costs nothing: capacity here is one turn at a time
1266
+ // regardless, and the poll interval is 5s. It only stops the loop
1267
+ // claiming work it has no way to begin.
1174
1268
  const { events = [] } = await client.get('/api/agents/runtime/events', {
1175
- agentName, instanceId, limit: 10,
1269
+ agentName, instanceId, limit: 1,
1176
1270
  });
1177
1271
  consecutiveAuthErrors = 0;
1272
+ consecutivePollFailures = 0;
1178
1273
  for (const event of events) {
1179
1274
  if (!running) break;
1180
1275
  let result;
@@ -1265,6 +1360,33 @@ export const performRun = ({
1265
1360
  running = false;
1266
1361
  return;
1267
1362
  }
1363
+ } else {
1364
+ // TASK-025: everything that is not an auth rejection used to fall
1365
+ // through to `onError` with `nextPollDelayMs` still at `intervalMs`,
1366
+ // because that variable is only reassigned in the spawn-retry branch
1367
+ // above and a failed fetch never reaches it. So a network outage
1368
+ // retried flat at the poll interval, forever, with one indistinct
1369
+ // line per attempt — 797 consecutive `fetch failed` across three
1370
+ // seats, ~66 minutes, and nothing that read as an outage.
1371
+ //
1372
+ // Deliberately NOT a stop. `MAX_AUTH_ERRORS` halts because a rejected
1373
+ // token cannot heal itself; a network failure usually can, and a seat
1374
+ // that stops on one is dead until a human notices.
1375
+ consecutivePollFailures += 1;
1376
+ const retry = pollRetryPolicy({
1377
+ consecutiveFailures: consecutivePollFailures,
1378
+ intervalMs,
1379
+ jitterRatio: spawnJitterRatio,
1380
+ });
1381
+ nextPollDelayMs = retry.delayMs;
1382
+ if (retry.escalate) {
1383
+ log(
1384
+ `poll failed ${consecutivePollFailures}x in a row `
1385
+ + `(${err?.message || 'unknown error'}) — backing off to `
1386
+ + `${formatRetryDelay(retry.delayMs)}`
1387
+ + `${retry.atCeiling ? ', at the ceiling' : ''}`,
1388
+ );
1389
+ }
1268
1390
  }
1269
1391
  onError?.(err);
1270
1392
  }
@@ -1663,6 +1785,7 @@ Docs:
1663
1785
  .option('--env <path>', 'Path to environment.json (ADR-008 — sandbox/skills/MCP)')
1664
1786
  .option('--import-memory [path]', 'Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory, or a given file/dir) into the agent after attach')
1665
1787
  .option('--yes', 'Skip the import confirmation prompt')
1788
+ .option('--wake-on-message', 'Wake this agent on every message in the pod, not only @mentions (ADR-018 ambient wake; default off). Change later with: commonly agent wake <name> on|off')
1666
1789
  .option('--instance <url>', 'Target Commonly instance')
1667
1790
  .action(async (adapterName, opts) => {
1668
1791
  const instanceUrl = resolveInstanceUrl(opts.instance);
@@ -1683,6 +1806,7 @@ Docs:
1683
1806
  podId: opts.pod,
1684
1807
  displayName: opts.display,
1685
1808
  envPath: envAbsPath,
1809
+ wakeOnMessage: Boolean(opts.wakeOnMessage),
1686
1810
  log: (line) => console.warn(`[attach] ${line}`),
1687
1811
  });
1688
1812
 
@@ -1705,6 +1829,9 @@ Docs:
1705
1829
  if (workspace) {
1706
1830
  console.log(`✓ Workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
1707
1831
  }
1832
+ if (opts.wakeOnMessage) {
1833
+ console.log('✓ Wake-on-message: ON (sees every pod message; answers only when addressed or genuinely useful)');
1834
+ }
1708
1835
 
1709
1836
  // Retention plan Phase C: the agent arrives whole. Import failure is
1710
1837
  // a warning, never an attach failure — the attach already succeeded
@@ -2113,6 +2240,38 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
2113
2240
  }
2114
2241
  });
2115
2242
 
2243
+ // ── wake (ADR-018 ambient wake toggle) ───────────────────────────────────
2244
+ agent
2245
+ .command('wake <name> <on|off>')
2246
+ .description('Turn wake-on-message on/off for an attached agent (off = @mentions and heartbeats only)')
2247
+ .option('--instance <url>', 'Target Commonly instance')
2248
+ .action(async (name, state, opts) => {
2249
+ const enabled = state === 'on';
2250
+ if (!enabled && state !== 'off') {
2251
+ console.error(`Expected "on" or "off", got "${state}"`);
2252
+ process.exit(1);
2253
+ }
2254
+ const record = loadAgentToken(name);
2255
+ if (!record) {
2256
+ console.error(`No token file for '${name}' — is it attached on this machine? (commonly agent list --local)`);
2257
+ process.exit(1);
2258
+ }
2259
+ const instanceUrl = resolveInstanceUrl(opts.instance || record.instanceUrl);
2260
+ const token = getToken(opts.instance || record.instanceUrl);
2261
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
2262
+ const client = createClient({ instance: instanceUrl, token });
2263
+ try {
2264
+ const result = await setWakeOnMessage({ client, record, enabled });
2265
+ console.log(`✓ Wake-on-message ${result.enabled ? 'ON' : 'OFF'} for ${result.agentName} in pod ${result.podId}`);
2266
+ console.log(result.enabled
2267
+ ? ' The agent now sees every message in the pod; its runtime decides whether to answer (NO_REPLY otherwise). No restart needed.'
2268
+ : ' The agent now wakes only on @mentions, replies to it, and heartbeats. No restart needed.');
2269
+ } catch (err) {
2270
+ console.error(`Failed: ${err.message}`);
2271
+ process.exit(1);
2272
+ }
2273
+ });
2274
+
2116
2275
  // ── heartbeat ─────────────────────────────────────────────────────────────
2117
2276
  agent
2118
2277
  .command('heartbeat <name>')
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { createInterface } from 'readline';
9
9
  import { login as apiLogin } from '../lib/api.js';
10
- import { saveInstance, LOCAL_URL } from '../lib/config.js';
10
+ import { saveInstance } from '../lib/config.js';
11
11
 
12
12
  const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve));
13
13
 
@@ -88,8 +88,8 @@ export const registerWhoami = (program) => {
88
88
  .command('whoami')
89
89
  .description('Show current auth state')
90
90
  .option('--instance <url>', 'Target instance')
91
- .action(async (opts) => {
92
- const { getActiveInstance, listInstances } = await import('../lib/config.js');
91
+ .action(async () => {
92
+ const { listInstances } = await import('../lib/config.js');
93
93
  const instances = listInstances();
94
94
 
95
95
  if (instances.length === 0) {
@@ -66,6 +66,7 @@ import {
66
66
  publicClaudeStateRoot,
67
67
  wrapArgvWithSeatbelt,
68
68
  } from '../sandbox/seatbelt.js';
69
+ import { buildMemoryPreamble } from '../memory-bridge.js';
69
70
 
70
71
  // See codex.js for the rationale on bumping the default + env override.
71
72
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -78,10 +79,7 @@ const DEFAULT_TIMEOUT_MS = (() => {
78
79
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
79
80
  })();
80
81
 
81
- const buildPrompt = (prompt, memoryLongTerm) => {
82
- if (!memoryLongTerm) return prompt;
83
- return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
84
- };
82
+ const buildPrompt = buildMemoryPreamble;
85
83
 
86
84
  const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
87
85
  const PUBLIC_DENIED_TOOLS = [
@@ -472,7 +470,13 @@ export default {
472
470
  async spawn(prompt, ctx = {}) {
473
471
  const isResume = !!ctx.sessionId;
474
472
  const sessionId = ctx.sessionId || randomUUID();
475
- const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
473
+ // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
474
+ // `null || ''` is `''` — which routed the unreadable case straight into
475
+ // the empty-memory branch and made the whole distinction unreachable from
476
+ // the only two paths that build a real prompt. buildPrompt handles
477
+ // undefined and '' as absence itself; it does not need a guard, it needs
478
+ // the value.
479
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
476
480
  const sessionFlag = isResume ? '--resume' : '--session-id';
477
481
  // Model pin from the ADR-008 environment spec. Absent it, claude picks its
478
482
  // own default — which is how a fleet of ten agents ended up running three
@@ -56,6 +56,7 @@ import {
56
56
  join,
57
57
  resolve as pathResolve,
58
58
  } from 'path';
59
+ import { buildMemoryPreamble } from '../memory-bridge.js';
59
60
 
60
61
  // Default timeout for a single codex spawn (exec mode).
61
62
  //
@@ -81,10 +82,7 @@ const DEFAULT_TIMEOUT_MS = (() => {
81
82
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
82
83
  })();
83
84
 
84
- const buildPrompt = (prompt, memoryLongTerm) => {
85
- if (!memoryLongTerm) return prompt;
86
- return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
87
- };
85
+ const buildPrompt = buildMemoryPreamble;
88
86
 
89
87
  // ── MCP wiring — codex consumes MCP servers via `-c mcp_servers.*` overrides ─
90
88
  //
@@ -417,7 +415,13 @@ export default {
417
415
  },
418
416
 
419
417
  async spawn(prompt, ctx = {}) {
420
- const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
418
+ // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
419
+ // `null || ''` is `''` — which routed the unreadable case straight into
420
+ // the empty-memory branch and made the whole distinction unreachable from
421
+ // the only two paths that build a real prompt. buildPrompt handles
422
+ // undefined and '' as absence itself; it does not need a guard, it needs
423
+ // the value.
424
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
421
425
 
422
426
  // Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
423
427
  // so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
package/src/lib/api.js CHANGED
@@ -43,6 +43,12 @@ export const createClient = ({ instance = null, token = null } = {}) => {
43
43
  body: JSON.stringify(body),
44
44
  }).then(handleResponse);
45
45
 
46
+ const patch = (path, body = {}) => fetch(`${baseUrl}${path}`, {
47
+ method: 'PATCH',
48
+ headers: headers(authToken),
49
+ body: JSON.stringify(body),
50
+ }).then(handleResponse);
51
+
46
52
  const del = (path) => fetch(`${baseUrl}${path}`, {
47
53
  method: 'DELETE',
48
54
  headers: headers(authToken),
@@ -70,7 +76,7 @@ export const createClient = ({ instance = null, token = null } = {}) => {
70
76
  };
71
77
 
72
78
  return {
73
- get, post, del, upload, baseUrl,
79
+ get, post, patch, del, upload, baseUrl,
74
80
  };
75
81
  };
76
82
 
@@ -540,16 +540,35 @@ export const splitForChat = (text, { limit = 400 } = {}) => {
540
540
  * fits in one message → post as-is
541
541
  * splits into ≤ maxChunks → post the chunks in order ("two short
542
542
  * messages beat one wall")
543
- * longer than a split answer → it is a document, not a message: upload
544
- * the FULL text as a file and post one
545
- * message the reply's own opening plus
546
- * the file card. Nothing is cut; the file
547
- * holds everything.
543
+ * longer than a split answer → post the FIRST chunk to the channel and
544
+ * continue the rest in a thread under it.
545
+ * one indivisible oversize unit → it is a genuine document: upload the FULL
546
+ * text and post one message the reply's
547
+ * own opening plus the file card.
548
548
  *
549
- * If the upload fails (older server, network), fall back to posting every
550
- * chunk: a message flood is a tone violation, silence or truncation is a
551
- * correctness violation, and the contract itself ranks content above tone
552
- * ("NEVER hit that by cutting content").
549
+ * The thread rung is new (Sam 57691) and it replaces attachment as the answer
550
+ * for PROSE overflow. Before threads existed, a long analysis had nowhere to
551
+ * go but a file, and that was the right workaround. It is now the wrong one:
552
+ * an attachment is un-quotable, un-followable, and an all-or-nothing read,
553
+ * so overflowing into one buries the tail of a reply in a surface nobody can
554
+ * respond to. A thread keeps every word addressable and scopes the read.
555
+ *
556
+ * This is also the layer the rule has to live at. The pod-context cue already
557
+ * tells agents "prose overflow goes in a thread, not an attachment" (#1176),
558
+ * and the cue could not have been obeyed: the model does not choose the
559
+ * delivery mode, THIS FUNCTION does, and it only knew how to attach. A cue
560
+ * that promises what the wrapper contradicts teaches the agent it is failing
561
+ * at something it never controlled.
562
+ *
563
+ * Attachment is kept for the case it was always right for — a single atomic
564
+ * unit over `attachThreshold` (a long fence, an unbreakable run). That is a
565
+ * document by construction, not prose that outgrew a message.
566
+ *
567
+ * If threading fails (older server with no threadRootId support, no id in the
568
+ * response), fall back to attach, then to posting every chunk: a message
569
+ * flood is a tone violation, silence or truncation is a correctness
570
+ * violation, and the contract ranks content above tone ("NEVER hit that by
571
+ * cutting content").
553
572
  */
554
573
  export const deliverChatReply = async ({
555
574
  client,
@@ -563,6 +582,24 @@ export const deliverChatReply = async ({
563
582
  }) => {
564
583
  const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
565
584
  const chunks = splitForChat(text, { limit });
585
+ // The runtime route uses HTTP 200 for a policy refusal: it is a completed
586
+ // request, but no message was created. Keep that distinction at the client
587
+ // boundary so every delivery mode shares it rather than treating a resolved
588
+ // promise as proof of a post.
589
+ const postMessage = (body) => client.post(messagesPath, body);
590
+ const refused = (response, messages, attemptedMessages) => ({
591
+ mode: 'refused',
592
+ messages,
593
+ attemptedMessages,
594
+ refused: true,
595
+ reason: response.reason || 'message_refused',
596
+ ...(typeof response.guidance === 'string' && response.guidance
597
+ ? { guidance: response.guidance }
598
+ : {}),
599
+ ...(typeof response.consecutive === 'number'
600
+ ? { consecutive: response.consecutive }
601
+ : {}),
602
+ });
566
603
  // An atomic unit (a fenced block, an unbreakable word-run) can exceed the
567
604
  // limit by construction — splitForChat keeps it whole rather than breaking
568
605
  // its rendering. The tone contract's own rule covers it: over ~800 chars of
@@ -571,15 +608,71 @@ export const deliverChatReply = async ({
571
608
  // the gate (found by the fleet's implementation audit, Sharpen msg 53018).
572
609
  const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
573
610
  if (chunks.length <= 1 && !hasIndivisibleOversize) {
574
- await client.post(messagesPath, { content: chunks[0] ?? text });
611
+ const response = await postMessage({ content: chunks[0] ?? text });
612
+ if (response?.refused === true) return refused(response, 0, 1);
575
613
  return { mode: 'single', messages: 1 };
576
614
  }
577
615
  if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
616
+ let messages = 0;
578
617
  for (const chunk of chunks) {
579
618
  // eslint-disable-next-line no-await-in-loop
580
- await client.post(messagesPath, { content: chunk }); // in order, so the reply reads top-down
619
+ const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
620
+ if (response?.refused === true) return refused(response, messages, chunks.length);
621
+ messages += 1;
622
+ }
623
+ return { mode: 'split', messages };
624
+ }
625
+ // PROSE OVERFLOW → THREAD. Only when nothing is indivisibly oversize: a
626
+ // fence too big to split is a document and belongs in the attach rung below.
627
+ if (!hasIndivisibleOversize) {
628
+ // A COUNT, not a flag. The recovery below resumes from here, and a boolean
629
+ // can only distinguish "nothing posted" from "something posted" — it cannot
630
+ // say how much. Fail a continuation at chunk 3 with a boolean and chunks 1
631
+ // and 2 are already in the thread, then get posted again top-level; the
632
+ // reader sees them twice. Getting this wrong is silent, which is also why
633
+ // the attach rung below leads with `chunks[0]`: falling through after a
634
+ // successful headline duplicates the opening line. Two of this suite's
635
+ // existing tests caught that one, and none caught this one, because both
636
+ // fail at the root-id step before any continuation has posted.
637
+ let posted = 0;
638
+ try {
639
+ const rootRes = await postMessage({ content: chunks[0] });
640
+ if (rootRes?.refused === true) return refused(rootRes, 0, chunks.length);
641
+ posted = 1;
642
+ // The runtime route answers `res.json(result)` with the created row on
643
+ // `result.message`. Accept either id field; refuse to guess if neither
644
+ // is present, because a continuation posted with a missing root would
645
+ // silently become another top-level message — the exact flood this rung
646
+ // exists to prevent.
647
+ const rootId = rootRes?.message?.id ?? rootRes?.message?._id ?? rootRes?.id ?? rootRes?._id;
648
+ if (!rootId) throw new Error('no message id in post response — cannot root the thread');
649
+ for (const chunk of chunks.slice(1)) {
650
+ // eslint-disable-next-line no-await-in-loop
651
+ const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
652
+ if (response?.refused === true) return refused(response, posted, chunks.length);
653
+ posted += 1;
654
+ }
655
+ return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) };
656
+ } catch (err) {
657
+ if (posted > 0) {
658
+ // The opening is already in the room. Post the REMAINDER top-level —
659
+ // never the whole text again. This is the old flood, minus the
660
+ // duplicate, and it is still preferable to attaching: content ranks
661
+ // above tone, and the reader would otherwise see the same paragraph
662
+ // twice with the rest hidden in a file.
663
+ log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
664
+ for (const chunk of chunks.slice(posted)) {
665
+ // eslint-disable-next-line no-await-in-loop
666
+ const response = await postMessage({ content: chunk });
667
+ if (response?.refused === true) return refused(response, posted, chunks.length);
668
+ posted += 1;
669
+ }
670
+ return { mode: 'thread-fallback', messages: chunks.length };
671
+ }
672
+ // Nothing reached the room, so the attach rung below is free to lead
673
+ // with the opening as it always did.
674
+ log(`thread headline failed (${err.message}) — falling back to attach`);
581
675
  }
582
- return { mode: 'split', messages: chunks.length };
583
676
  }
584
677
  try {
585
678
  const uploaded = await client.upload(`/api/agents/runtime/pods/${podId}/uploads`, {
@@ -596,14 +689,18 @@ export const deliverChatReply = async ({
596
689
  const lead = chunks[0] && chunks[0].length <= limit
597
690
  ? chunks[0]
598
691
  : '(reply too large for chat — attached in full)';
599
- await client.post(messagesPath, { content: `${lead}\n\n${directive}` });
692
+ const response = await postMessage({ content: `${lead}\n\n${directive}` });
693
+ if (response?.refused === true) return refused(response, 0, 1);
600
694
  return { mode: 'attach', messages: 1 };
601
695
  } catch (err) {
602
696
  log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
697
+ let messages = 0;
603
698
  for (const chunk of chunks) {
604
699
  // eslint-disable-next-line no-await-in-loop
605
- await client.post(messagesPath, { content: chunk });
700
+ const response = await postMessage({ content: chunk });
701
+ if (response?.refused === true) return refused(response, messages, chunks.length);
702
+ messages += 1;
606
703
  }
607
- return { mode: 'split-fallback', messages: chunks.length };
704
+ return { mode: 'split-fallback', messages };
608
705
  }
609
706
  };
@@ -18,7 +18,7 @@ import {
18
18
  readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile,
19
19
  } from 'fs/promises';
20
20
  import { existsSync } from 'fs';
21
- import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path';
21
+ import { isAbsolute, join, resolve as pathResolve, basename } from 'path';
22
22
  import { homedir } from 'os';
23
23
 
24
24
  // ── Schema — keep the allow-list narrow; ADR-008 §invariants #1+#2 ──────────
@@ -21,20 +21,68 @@
21
21
 
22
22
  export const SOURCE_RUNTIME = 'local-cli';
23
23
 
24
+ /**
25
+ * The turn preamble, shared by every adapter so the two cannot drift.
26
+ *
27
+ * Non-empty memory is prepended verbatim — that path is load-bearing and is
28
+ * not touched here.
29
+ *
30
+ * The EMPTY case used to return the bare prompt, which made two very different
31
+ * states byte-identical from inside the session: an agent that has never saved
32
+ * anything, and an agent on a runtime with no memory bridge at all. A seat
33
+ * cannot adopt a habit whose surface it has no evidence exists, so the empty
34
+ * case now says so.
35
+ *
36
+ * It names `long_term` specifically because that is the ONLY section read back
37
+ * (`readLongTerm` returns `sections.long_term.content`). A write to `daily` —
38
+ * the section whose name most invites exactly this use — succeeds, returns 200,
39
+ * and is never seen again; the write and the silence are indistinguishable from
40
+ * a correct round trip. Naming the section is the whole point of the cue.
41
+ */
42
+ export const buildMemoryPreamble = (prompt, memoryLongTerm) => {
43
+ // `null` is the UNREADABLE signal, and it is deliberately not the same value
44
+ // as `''`. Telling a seat whose token was revoked that "nothing has ever been
45
+ // saved here" is a false claim about its own history, and it is the same
46
+ // defect this cue exists to fix — one state over. When we could not read, say
47
+ // that, and say nothing about what is stored.
48
+ if (memoryLongTerm === null) {
49
+ return `=== Context (your persistent memory) ===\n`
50
+ + `(unreadable this turn — the memory read failed, so this says NOTHING `
51
+ + `about what you have saved. Do not treat it as empty and do not re-save `
52
+ + `state you may already hold.)\n`
53
+ + `=== Current turn ===\n${prompt}`;
54
+ }
55
+ if (memoryLongTerm) {
56
+ return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
57
+ }
58
+ return `=== Context (your persistent memory) ===\n`
59
+ + `(empty — nothing has ever been saved here)\n`
60
+ + `Only the \`long_term\` section is read back into this prompt. To make `
61
+ + `something survive your next session, call commonly_save_my_memory({ `
62
+ + `section: 'long_term', content: '...' }). A write to any other section `
63
+ + `succeeds and is never shown to you again.\n`
64
+ + `=== Current turn ===\n${prompt}`;
65
+ };
66
+
24
67
  export const readLongTerm = async (client, { onError } = {}) => {
25
68
  try {
26
69
  const body = await client.get('/api/agents/runtime/memory');
27
70
  return body?.sections?.long_term?.content || '';
28
71
  } catch (err) {
29
- // A fresh agent has no memory row yet treat as empty rather than fail
30
- // the spawn. The kernel upserts on first write. For anything OTHER than
31
- // a 404 (auth revoked, backend down, network out), surface via onError
32
- // so the user sees "something's wrong with memory" instead of a silently
33
- // context-less agent.
34
- if (err?.status && err.status !== 404) {
35
- onError?.(err);
36
- }
37
- return '';
72
+ // A 404 is the ONLY error that means what '' means: a fresh agent with no
73
+ // memory row yet. The kernel upserts on first write, so this is genuine
74
+ // absence and the empty cue is true.
75
+ if (err?.status === 404) return '';
76
+ // Everything else — auth revoked, 500, connection refused — is a failure to
77
+ // READ, which tells us nothing about what is stored. Returning '' here made
78
+ // the caller assert emptiness on no evidence.
79
+ //
80
+ // `err.status` is undefined for a transport failure, so the old guard
81
+ // (`err?.status && err.status !== 404`) skipped onError precisely when the
82
+ // backend was unreachable: the loudest condition was the silent one, and
83
+ // nothing contradicted the false cue.
84
+ onError?.(err);
85
+ return null;
38
86
  }
39
87
  };
40
88
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Retry policy for the run loop's own FETCH failures (TASK-025).
3
+ *
4
+ * `spawn-retry.js` bounds failures of the subprocess a poll produces. This
5
+ * bounds failures of the poll itself — the request to
6
+ * `/api/agents/runtime/events` that has to succeed before there is anything to
7
+ * spawn. They are different failures with different remedies and, until now,
8
+ * only one of them had a policy.
9
+ *
10
+ * The gap, measured: `agent run`'s tick set `nextPollDelayMs = intervalMs` and
11
+ * only ever reassigned it inside the spawn-retry branch. A fetch that threw
12
+ * never reached that branch, so a network failure retried at a flat 5s
13
+ * forever — no backoff, no ceiling, and no escalation. 797 consecutive
14
+ * `fetch failed` ran across three seats that way: ~66 minutes at 5s, and the
15
+ * only trace was one `onError` line per attempt in a log nobody was tailing.
16
+ *
17
+ * Two deliberate differences from the auth path directly above it:
18
+ *
19
+ * 1. NO STOP. `MAX_AUTH_ERRORS` halts the loop because a rejected token does
20
+ * not heal on its own — retrying is pure cost and the operator must act.
21
+ * A network failure is usually transient, and a seat that stops on one is
22
+ * dead until someone notices. So this backs off and stays alive.
23
+ *
24
+ * 2. ESCALATION IS THE POINT. The harm in the measured outage was not the
25
+ * retry rate, it was that 797 failures produced no signal distinguishable
26
+ * from one failure. `escalate` fires on a small set of thresholds so a
27
+ * sustained outage announces itself at widening intervals instead of
28
+ * disappearing into per-attempt noise.
29
+ */
30
+
31
+ // Same ceiling as the spawn path: past this, more waiting buys nothing and a
32
+ // human is the only thing that resolves it.
33
+ export const POLL_RETRY_MAX_MS = 15 * 60 * 1000;
34
+
35
+ // Backoff starts only after the first failure has been retried once at the
36
+ // normal interval, so a single blip costs nothing.
37
+ export const POLL_BACKOFF_AFTER = 1;
38
+
39
+ // Failure counts that emit a loud line. Chosen against the measured outage:
40
+ // at intervalMs=5000 these land at roughly 15s, 1min, 5min, 20min and 1h of
41
+ // sustained failure, so an operator reading the log sees the shape of the
42
+ // outage rather than 797 identical lines.
43
+ export const POLL_ESCALATE_AT = Object.freeze([3, 10, 30, 60, 120]);
44
+
45
+ /**
46
+ * Next poll delay after a failed fetch, and whether this attempt should be
47
+ * announced loudly.
48
+ *
49
+ * Exponential from `intervalMs`, bounded by POLL_RETRY_MAX_MS. Jitter is the
50
+ * same anti-herd offset the spawn path uses — without it, every seat that lost
51
+ * the same upstream retries in lockstep and re-creates the thundering herd on
52
+ * recovery.
53
+ */
54
+ export const pollRetryPolicy = ({
55
+ consecutiveFailures,
56
+ intervalMs,
57
+ jitterRatio = 0,
58
+ }) => {
59
+ const safeIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 5000;
60
+ const failureCount = Number.isInteger(consecutiveFailures) && consecutiveFailures > 0
61
+ ? consecutiveFailures
62
+ : 1;
63
+
64
+ const steps = Math.max(0, failureCount - POLL_BACKOFF_AFTER);
65
+ const raw = safeIntervalMs * (2 ** steps);
66
+ const bounded = Math.min(POLL_RETRY_MAX_MS, raw);
67
+
68
+ // Clamp rather than trust the caller: a jitterRatio above the cap would
69
+ // widen the herd window instead of narrowing it.
70
+ const safeJitter = Number.isFinite(jitterRatio)
71
+ ? Math.min(0.2, Math.max(0, jitterRatio))
72
+ : 0;
73
+
74
+ return {
75
+ delayMs: Math.round(bounded * (1 + safeJitter)),
76
+ escalate: POLL_ESCALATE_AT.includes(failureCount),
77
+ atCeiling: bounded >= POLL_RETRY_MAX_MS,
78
+ };
79
+ };
80
+
81
+ export default pollRetryPolicy;