@commonlyai/cli 0.1.80 → 0.1.84

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.80",
3
+ "version": "0.1.84",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -48,6 +48,7 @@ import {
48
48
  readPodFocus,
49
49
  } from '../lib/pod-focus.js';
50
50
  import { claimReleaseFor, ackResultFor } from '../lib/claim-outcome.js';
51
+ import { prepareCommitAttribution } from '../lib/commit-attribution.js';
51
52
  import { detectBwrap } from '../lib/sandbox/bwrap.js';
52
53
  import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
53
54
  import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
@@ -190,6 +191,7 @@ export const bootstrapAgentRecordFromEnv = async ({
190
191
 
191
192
  return {
192
193
  agentName,
194
+ displayName: identity?.displayName || primary?.displayName || agentName,
193
195
  instanceId: identity?.instanceId || primary?.instanceId || 'default',
194
196
  podId: primary?.podId || null,
195
197
  instanceUrl,
@@ -731,6 +733,7 @@ export const performAttach = async ({
731
733
  runtimeToken,
732
734
  detected,
733
735
  wrappedCli: adapter.name,
736
+ displayName: installation.displayName || displayName || agentName,
734
737
  environment,
735
738
  workspace,
736
739
  };
@@ -907,6 +910,7 @@ export const performRun = ({
907
910
  token,
908
911
  adapter,
909
912
  agentName,
913
+ displayName = null,
910
914
  instanceId = 'default',
911
915
  podId = null,
912
916
  environment = null,
@@ -1370,11 +1374,28 @@ export const performRun = ({
1370
1374
  };
1371
1375
  }
1372
1376
  let result;
1377
+ let commitAttribution = null;
1373
1378
  try {
1379
+ // The local CLI is a wrapper for a Commonly seat, so its Git commits
1380
+ // carry the seat and the adapter's selected model/effort. `stub` exists
1381
+ // only for tests and has no commit-making child process.
1382
+ if (adapter?.name !== 'stub') {
1383
+ commitAttribution = prepareCommitAttribution({
1384
+ cwd: agentCwd,
1385
+ env: process.env,
1386
+ agentName,
1387
+ displayName,
1388
+ instanceId,
1389
+ adapter: adapter?.name,
1390
+ model: seatEnvironment?.model,
1391
+ effort: seatEnvironment?.effort,
1392
+ });
1393
+ }
1374
1394
  result = await adapter.spawn(frameDecisionForkRule(promptWithFocus), {
1375
1395
  sessionId,
1376
1396
  cwd: agentCwd,
1377
- env: process.env,
1397
+ env: commitAttribution?.env || process.env,
1398
+ commitAttributionEnv: commitAttribution?.sandboxEnv || null,
1378
1399
  memoryLongTerm,
1379
1400
  environment: seatEnvironment,
1380
1401
  // Runtime context the Claude/Codex adapters expose only to their
@@ -1389,10 +1410,14 @@ export const performRun = ({
1389
1410
  metadata: { event },
1390
1411
  });
1391
1412
  } finally {
1392
- // Best-effort by construction: `close` logs and returns on a failed
1393
- // revoke, so a turn that already produced an answer is not failed by
1394
- // cleanup. The boot sweep collects anything left behind.
1395
- await lease.close();
1413
+ try {
1414
+ commitAttribution?.cleanup();
1415
+ } finally {
1416
+ // Best-effort by construction: `close` logs and returns on a failed
1417
+ // revoke, so a turn that already produced an answer is not failed by
1418
+ // cleanup. The boot sweep collects anything left behind.
1419
+ await lease.close();
1420
+ }
1396
1421
  }
1397
1422
 
1398
1423
  if (result.newSessionId) {
@@ -2511,7 +2536,7 @@ Docs:
2511
2536
  const envAbsPath = opts.env ? pathResolve(opts.env) : null;
2512
2537
  const {
2513
2538
  installation, instanceId, runtimeToken, detected, wrappedCli,
2514
- environment, workspace,
2539
+ displayName, environment, workspace,
2515
2540
  } = await performAttach({
2516
2541
  client,
2517
2542
  adapterName,
@@ -2525,6 +2550,7 @@ Docs:
2525
2550
 
2526
2551
  saveAgentToken(opts.name, {
2527
2552
  agentName: opts.name,
2553
+ displayName,
2528
2554
  instanceId,
2529
2555
  podId: opts.pod,
2530
2556
  instanceUrl,
@@ -2717,6 +2743,7 @@ Docs:
2717
2743
  token: record.runtimeToken,
2718
2744
  adapter,
2719
2745
  agentName: record.agentName,
2746
+ displayName: record.displayName || record.agentName,
2720
2747
  instanceId: record.instanceId,
2721
2748
  podId: record.podId,
2722
2749
  environment: record.environment || null,
@@ -21,6 +21,7 @@ import {
21
21
  formatItemLine,
22
22
  nextCursorFrom,
23
23
  parseCursor,
24
+ parseWindowMs,
24
25
  readCursorFile,
25
26
  readInboxToken,
26
27
  resolveAccountLabel,
@@ -84,6 +85,9 @@ export const runInboxList = async (opts = {}, deps = {}) => {
84
85
  // Validated before the request: a typo in --kind must not cost a network
85
86
  // round trip, and must not read a queue the caller did not ask for.
86
87
  const kind = normalizeKind(opts.kind);
88
+ // Validated here with --kind, for the same reason: a typo must not cost a
89
+ // request, and a caller who asked for a window must not silently get another.
90
+ const windowMs = parseWindowMs(opts.window);
87
91
  // Read and check the cursor BEFORE connecting: this is a local-file fact, and
88
92
  // a misuse of the flags must not cost even the identity request that
89
93
  // resolves the account.
@@ -103,23 +107,28 @@ export const runInboxList = async (opts = {}, deps = {}) => {
103
107
  + `--kind ${cursorIn.kind} --cursor-file <path>.${cursorIn.kind}.`,
104
108
  );
105
109
  }
106
- const sinceMs = cursorIn ? cursorIn.ms : null;
107
- const since = cursorIn ? cursorIn.at : null;
110
+ // The window: W behind the cursor's own stamp, not at it. A row stamped before
111
+ // the cursor can commit after the read that wrote it, and it is that read's
112
+ // `since` — not the row — that made it invisible; a read starting W earlier
113
+ // sees it. The ids already delivered inside the window are what keep the
114
+ // re-read from printing the old rows again (see lib/inbox.js).
115
+ const sinceMs = cursorIn ? cursorIn.ms - windowMs : null;
116
+ const since = sinceMs === null ? null : new Date(sinceMs).toISOString();
108
117
 
109
118
  const {
110
119
  baseUrl, request, account,
111
120
  } = await connect(opts, { env, fetchImpl, sleep });
112
121
 
113
- const { items, meta } = await fetchQueue(request, { podId: opts.pod });
122
+ const { items, meta } = await fetchQueue(request, { podId: opts.pod, since });
114
123
  const selected = filterItems(items, {
115
124
  kind,
116
125
  sinceMs,
117
- // The ids already delivered at the cursor's own millisecond. An item that
118
- // shares that millisecond but is not among them arrived after the cursor
119
- // was written, and is exactly the row a bare `ms > since` loses.
126
+ // Everything already delivered inside the window. An item of the same id in
127
+ // the response is a re-delivery, not news; an item that merely shares its
128
+ // instant with one is news, which is why this dedupes by id and not by time.
120
129
  seenIds: cursorIn ? cursorIn.ids : [],
121
130
  });
122
- const cursor = nextCursorFrom(selected, cursorIn, kind);
131
+ const cursor = nextCursorFrom(selected, cursorIn, kind, windowMs);
123
132
 
124
133
  if (opts.json) {
125
134
  log(JSON.stringify({
@@ -138,7 +147,7 @@ export const runInboxList = async (opts = {}, deps = {}) => {
138
147
  const counts = countsLine(meta?.countsByKind);
139
148
  log(`open: ${Number.isInteger(meta?.count) ? meta.count : items.length}${counts ? ` (${counts})` : ''}`);
140
149
  if (selected.length === 0) {
141
- log(since ? `(no items newer than ${since})` : '(queue is empty)');
150
+ log(since ? `(nothing new; the window since ${since} was re-read)` : '(queue is empty)');
142
151
  }
143
152
  for (const item of selected) log(formatItemLine(item));
144
153
  log(`cursor: ${cursor ? cursor.at : ''}`);
@@ -226,16 +235,23 @@ Examples:
226
235
  $ commonly inbox list --json | jq '.items[].title'
227
236
  $ commonly inbox list --kind decision --pod <podId>
228
237
  $ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
238
+ $ commonly inbox list --cursor-file ~/.commonly/inbox/c.cursor --window 300 # widen the re-read to 5m
229
239
  $ commonly inbox ack <attentionItemId>
230
240
  $ commonly inbox choose <decisionId> 2
231
241
 
232
- Cost: \`list\` reads the WHOLE queue, one request per 50 items — 550 open
233
- items is 11 requests — and /api/activity is limited to 60 a minute **keyed on
242
+ Cost: with no cursor, \`list\` reads the WHOLE queue, one request per 50 items —
243
+ 550 open items is 11 requests. /api/activity is limited to 60 a minute **keyed on
234
244
  the caller's IP**, so every session on one host (and the operator's browser)
235
- shares that budget. A 429 is backed off and retried twice on the server's own
236
- \`Retry-After\`, then reported. A cursor narrows what is PRINTED, not what is
237
- read: the route has no created-since filter yet, so \`--pod\` is the one scope
238
- that narrows the request itself.
245
+ shares that budget; a 429 is backed off and retried twice on the server's own
246
+ \`Retry-After\`, then reported.
247
+
248
+ With \`--cursor-file\`, the read instead asks for \`createdAt >= cursor - W\`
249
+ (\`--window\`, default 60s), so a watcher tick is ONE request. W is the margin
250
+ over write-commit latency plus the writer's clock offset — a row is stamped before
251
+ its write commits, so resuming exactly at the cursor would lose it. Rows already
252
+ delivered inside W are dropped by id, which is the re-delivery the window is for.
253
+ The counts printed in the header still describe the whole open set; only the rows
254
+ inside the window are printed.
239
255
 
240
256
  \`ack\` deliberately ships WITHOUT an \`--all\`: a burst of acks shares that same
241
257
  per-IP budget, so a paced loop is the caller's decision and a bulk
@@ -253,6 +269,7 @@ their attention as runtime events, not from this queue.
253
269
  .option('--token-file <path>', INBOX_TOKEN_HELP)
254
270
  .option('--since <cursor>', 'ISO-8601 timestamp; only items created after it')
255
271
  .option('--cursor-file <path>', 'Read the cursor from this file and write the advanced cursor back')
272
+ .option('--window <seconds>', 'Re-read this far behind the cursor, dropping ids already delivered (default 60; 0 = exact cursor)')
256
273
  .option('--kind <kind>', `Only this kind: ${INBOX_KINDS.join('|')}`)
257
274
  .option('--pod <podId>', 'Only this pod')
258
275
  .option('--json', 'Emit one JSON object (with the resolved account and the cursor)')
@@ -45,7 +45,7 @@
45
45
 
46
46
  import { spawn as childSpawn, spawnSync } from 'child_process';
47
47
  import { createHash, randomUUID } from 'crypto';
48
- import { realpathSync } from 'fs';
48
+ import { accessSync, constants, realpathSync } from 'fs';
49
49
  import {
50
50
  chmod,
51
51
  lstat,
@@ -73,6 +73,7 @@ import {
73
73
  wrapArgvWithSeatbelt,
74
74
  } from '../sandbox/seatbelt.js';
75
75
  import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
76
+ import { supportedGitHooks } from '../commit-attribution.js';
76
77
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
77
78
  import { prepareMcpSpawn } from '../mcp-home.js';
78
79
  import { buildMemoryPreamble } from '../memory-bridge.js';
@@ -90,6 +91,22 @@ const DEFAULT_TIMEOUT_MS = (() => {
90
91
  })();
91
92
 
92
93
  const buildPrompt = buildMemoryPreamble;
94
+ const RUNTIME_CREDENTIAL_PLACEHOLDER = '${COMMONLY_AGENT_TOKEN}';
95
+
96
+ const QUOTE_SHELL_ARGUMENT = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
97
+
98
+ const HEADER_TOKEN_HELPER_SOURCE = `import { readFileSync } from 'node:fs';
99
+
100
+ const credentialFile = process.env.${CREDENTIAL_FILE_VAR};
101
+ if (!credentialFile) throw new Error('missing ${CREDENTIAL_FILE_VAR}');
102
+ const token = readFileSync(credentialFile, 'utf8').trim();
103
+ if (!token) throw new Error('empty ${CREDENTIAL_FILE_VAR}');
104
+ const templates = JSON.parse(readFileSync(process.argv[2], 'utf8'));
105
+ const headers = Object.fromEntries(
106
+ Object.entries(templates).map(([name, parts]) => [name, parts.join(token)]),
107
+ );
108
+ process.stdout.write(JSON.stringify(headers) + '\\n');
109
+ `;
93
110
 
94
111
  const PUBLIC_DENIED_TOOLS = [
95
112
  'WebSearch',
@@ -289,9 +306,9 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl
289
306
 
290
307
  // Keep Commonly placeholders in the MCP JSON and expose their values only in
291
308
  // Claude's per-spawn environment. Claude Code natively expands ${VAR} in MCP
292
- // command/args/env/url/headers fields. Substituting here used to materialize the raw
293
- // cm_agent_* bearer token in a transient JSON file, which made the token
294
- // readable to any co-confined child allowed to read that config directory.
309
+ // command/args/env/url/header fields. Token-bearing HTTP headers are routed
310
+ // through headersHelper below, because a literal expansion would put the
311
+ // credential in Claude's environment and every MCP child's environment.
295
312
  //
296
313
  // Recognised placeholders:
297
314
  // ${COMMONLY_TOKEN_FILE} — the PATH of this spawn's credential file
@@ -323,20 +340,67 @@ const buildMcpExpansionEnv = (mcpConfig, ctx) => {
323
340
  // added only when the value is non-empty AND the declaration still references
324
341
  // it — and `serialized` is the config AFTER the credential rewrite, which is
325
342
  // what moves the default declaration off this value and onto the file. A
326
- // reference the rewrite cannot move (args, url, headers, or a string that
327
- // merely contains the placeholder) keeps the value for that spawn, because
328
- // claude substitutes those literally and there is no file channel for them.
329
- // That is the measured carve-out, and createMcpConfig warns when it applies
330
- // so the seat still carrying the value is named rather than assumed fixed.
343
+ // reference that cannot use the file channel (args, url, unsupported or
344
+ // non-HTTP headers, or a string that merely contains the placeholder) keeps
345
+ // the value for that spawn. createMcpConfig warns when this carve-out applies.
331
346
  COMMONLY_AGENT_TOKEN: ctx.runtimeToken || '',
332
347
  };
333
348
  const output = {};
334
349
  for (const [key, value] of Object.entries(values)) {
335
- if (value && serialized.includes(`\${${key}}`)) output[key] = value;
350
+ const referenced = serialized.includes(`\${${key}}`)
351
+ || (key === CREDENTIAL_FILE_VAR && ctx.headersHelperUsesCredentialFile === true);
352
+ if (value && referenced) output[key] = value;
336
353
  }
337
354
  return output;
338
355
  };
339
356
 
357
+ const isHttpTransport = (server) => {
358
+ const transport = String(server?.transport || server?.type || '').trim().toLowerCase();
359
+ return transport === 'http' || transport === 'streamable-http';
360
+ };
361
+
362
+ const getTokenHeaderTemplates = (server) => {
363
+ if (!isHttpTransport(server) || !server.headers || typeof server.headers !== 'object') return null;
364
+ const templates = Object.fromEntries(
365
+ Object.entries(server.headers)
366
+ .filter(([, value]) => typeof value === 'string' && value.includes(RUNTIME_CREDENTIAL_PLACEHOLDER))
367
+ .map(([name, value]) => [name, value.split(RUNTIME_CREDENTIAL_PLACEHOLDER)]),
368
+ );
369
+ return Object.keys(templates).length > 0 ? templates : null;
370
+ };
371
+
372
+ const writePrivateFile = async (path, contents) => {
373
+ await writeFile(path, contents, { encoding: 'utf8', mode: 0o600 });
374
+ await chmod(path, 0o600);
375
+ };
376
+
377
+ // Claude's headersHelper is invoked when an HTTP connection is established and
378
+ // again after an authentication retry. Keep the token out of its config and
379
+ // environment: the helper reads the current per-spawn file on every invocation.
380
+ const createTokenHeaderHelpers = async (mcpServers, dir) => {
381
+ const servers = mcpServers
382
+ .map((server, index) => ({ server, index, templates: getTokenHeaderTemplates(server) }))
383
+ .filter((item) => item.templates);
384
+ if (servers.length === 0) return new Map();
385
+
386
+ const scriptPath = join(dir, 'headers-helper.mjs');
387
+ await writePrivateFile(scriptPath, HEADER_TOKEN_HELPER_SOURCE);
388
+ const helpers = new Map();
389
+ for (const { server, index, templates } of servers) {
390
+ const templatesPath = join(dir, `headers-${index}.json`);
391
+ await writePrivateFile(templatesPath, JSON.stringify(templates));
392
+ helpers.set(server.name, {
393
+ command: [
394
+ QUOTE_SHELL_ARGUMENT(process.execPath),
395
+ QUOTE_SHELL_ARGUMENT(scriptPath),
396
+ QUOTE_SHELL_ARGUMENT(templatesPath),
397
+ ].join(' '),
398
+ headerNames: Object.keys(templates),
399
+ });
400
+ }
401
+ return helpers;
402
+ };
403
+
340
404
  const buildMcpConfig = (mcpServers, ctx = {}) => {
341
405
  // Shape: `{ mcpServers: { <name>: { ... } } }` — the standard MCP client
342
406
  // config, which claude's `--mcp-config` reads directly.
@@ -367,7 +431,13 @@ const buildMcpConfig = (mcpServers, ctx = {}) => {
367
431
  label: 'claude',
368
432
  }).env;
369
433
  }
370
- if (server.headers) entry.headers = { ...server.headers };
434
+ const headerHelper = ctx.dynamicHeaderHelpers?.get(server.name);
435
+ if (server.headers) {
436
+ const headers = { ...server.headers };
437
+ for (const headerName of headerHelper?.headerNames || []) delete headers[headerName];
438
+ if (Object.keys(headers).length > 0 || !headerHelper) entry.headers = headers;
439
+ }
440
+ if (headerHelper) entry.headersHelper = headerHelper.command;
371
441
  mcpServersMap[server.name] = entry;
372
442
  }
373
443
  return { mcpServers: mcpServersMap };
@@ -389,7 +459,14 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
389
459
  // config — no second lifecycle to get wrong — and so it is inside the one
390
460
  // non-workspace path the Seatbelt profile admits.
391
461
  credential = writeCredentialFile(ctx.runtimeToken, { agentName: ctx.agentName || 'agent', root: dir });
392
- const config = buildMcpConfig(mcpServers, { ...ctx, credentialFile: credential?.path || null });
462
+ const dynamicHeaderHelpers = credential?.path
463
+ ? await createTokenHeaderHelpers(mcpServers, dir)
464
+ : new Map();
465
+ const config = buildMcpConfig(mcpServers, {
466
+ ...ctx,
467
+ credentialFile: credential?.path || null,
468
+ dynamicHeaderHelpers,
469
+ });
393
470
  await writeFile(
394
471
  file,
395
472
  JSON.stringify(config, null, 2),
@@ -401,12 +478,13 @@ const createMcpConfig = async (mcpServers, ctx = {}) => {
401
478
  const expansionEnv = buildMcpExpansionEnv(config, {
402
479
  ...ctx,
403
480
  credentialFile: credential?.path || null,
481
+ headersHelperUsesCredentialFile: dynamicHeaderHelpers.size > 0,
404
482
  });
405
483
  if (expansionEnv[CREDENTIAL_KEY]) {
406
484
  // eslint-disable-next-line no-console
407
485
  console.warn(
408
486
  `[claude] this declaration references ${CREDENTIAL_KEY} in a field claude `
409
- + 'substitutes literally (args, url, headers, or a larger string), so the '
487
+ + 'substitutes literally (args, url, unsupported headers, or a larger string), so the '
410
488
  + "value stays in claude's environment for this spawn and every MCP child "
411
489
  + `inherits it. Put the credential on the entry's env to get the file channel.`,
412
490
  );
@@ -501,6 +579,23 @@ const prepareArgv = async (innerArgv, ctx) => {
501
579
  // get the jail only — a derived Linux seat kept Bash/Write/WebFetch and ran on
502
580
  // the operator's own Claude settings inside the namespace (Vera 69578).
503
581
  const publicBwrapSandbox = sandboxTrust === 'public' && sandboxMode === 'bwrap';
582
+ const commitHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
583
+ const originalHooksPath = ctx.commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
584
+ const commitHookExecutables = commitHooksPath
585
+ ? supportedGitHooks().map((hookName) => join(commitHooksPath, hookName))
586
+ : [];
587
+ const originalHookExecutables = originalHooksPath
588
+ ? supportedGitHooks()
589
+ .map((hookName) => join(originalHooksPath, hookName))
590
+ .filter((hookPath) => {
591
+ try {
592
+ accessSync(hookPath, constants.X_OK);
593
+ return true;
594
+ } catch {
595
+ return false;
596
+ }
597
+ })
598
+ : [];
504
599
  // A public trust MUST resolve to an enforced mode. Absent mode is resolved
505
600
  // above; anything else that lands here (the literal 'none', a typo, a
506
601
  // non-string) used to fall through to the bare `claude` spawn below — an
@@ -539,7 +634,7 @@ const prepareArgv = async (innerArgv, ctx) => {
539
634
  claudePath: claudeBin,
540
635
  statePath: ctx.publicClaudeState.statePath,
541
636
  mcpConfigDir: ctx.mcpConfigDir,
542
- executablePaths: mcpExecutables,
637
+ executablePaths: [...mcpExecutables, ...commitHookExecutables, ...originalHookExecutables],
543
638
  });
544
639
  return {
545
640
  cmd: wrapped[0],
@@ -568,7 +663,7 @@ const prepareArgv = async (innerArgv, ctx) => {
568
663
  const claudeBin = resolveClaudePath(claudeEnv);
569
664
  const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
570
665
  workspacePath: ctx.cwd,
571
- readOnlyPaths: ctx.mcpConfigDir ? [ctx.mcpConfigDir] : [],
666
+ readOnlyPaths: [ctx.mcpConfigDir, commitHooksPath, originalHooksPath].filter(Boolean),
572
667
  });
573
668
  return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv };
574
669
  }
@@ -691,17 +786,21 @@ export default {
691
786
  agentName: ctx.agentName,
692
787
  });
693
788
  }
789
+ const claudeEnv = buildClaudeEnv(
790
+ ctx.env,
791
+ publicClaudeState,
792
+ mcpConfig?.expansionEnv,
793
+ mcpConfig?.credential?.path || null,
794
+ );
795
+ if (publicNativeSandbox && ctx.commitAttributionEnv) {
796
+ Object.assign(claudeEnv, ctx.commitAttributionEnv);
797
+ }
694
798
  const spawnCtx = {
695
799
  ...ctx,
696
800
  mcpConfigPath: mcpConfig?.file || null,
697
801
  mcpConfigDir: mcpConfig?.dir || null,
698
802
  publicClaudeState,
699
- claudeEnv: buildClaudeEnv(
700
- ctx.env,
701
- publicClaudeState,
702
- mcpConfig?.expansionEnv,
703
- mcpConfig?.credential?.path || null,
704
- ),
803
+ claudeEnv,
705
804
  };
706
805
  const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx);
707
806
  const stdout = await runClaude({
@@ -39,6 +39,7 @@
39
39
 
40
40
  import { spawn as childSpawn, spawnSync } from 'child_process';
41
41
  import { createHash } from 'crypto';
42
+ import { accessSync, constants } from 'fs';
42
43
  import {
43
44
  chmod,
44
45
  lstat,
@@ -57,6 +58,7 @@ import {
57
58
  resolve as pathResolve,
58
59
  } from 'path';
59
60
  import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
61
+ import { supportedGitHooks } from '../commit-attribution.js';
60
62
  import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
61
63
  import { prepareMcpSpawn } from '../mcp-home.js';
62
64
  import { buildMemoryPreamble } from '../memory-bridge.js';
@@ -296,13 +298,38 @@ const preparePublicCodexHome = async (ctx) => {
296
298
  return publicHome;
297
299
  };
298
300
 
299
- const publicPermissionProfileFlags = (mode) => {
301
+ const publicPermissionProfileFlags = (
302
+ mode,
303
+ commitAttributionEnv = null,
304
+ ) => {
300
305
  if (!PUBLIC_SANDBOX_MODES.has(mode)) {
301
306
  throw new Error(
302
307
  `public codex agents require sandbox.mode=workspace or read-only, got ${mode || 'unset'}`,
303
308
  );
304
309
  }
305
310
  const workspaceAccess = mode === 'read-only' ? 'read' : 'write';
311
+ const commitHooksPath = commitAttributionEnv?.COMMONLY_AGENT_HOOKS_PATH || null;
312
+ const originalHooksPath = commitAttributionEnv?.COMMONLY_AGENT_ORIGINAL_HOOKS_PATH || null;
313
+ const originalHookPaths = originalHooksPath
314
+ ? supportedGitHooks()
315
+ .map((hookName) => join(originalHooksPath, hookName))
316
+ .filter((hookPath) => {
317
+ try {
318
+ accessSync(hookPath, constants.X_OK);
319
+ return true;
320
+ } catch {
321
+ return false;
322
+ }
323
+ })
324
+ : [];
325
+ // Preserve caller-supplied git config entries alongside the appended
326
+ // hooksPath entry. The explicit allowlist below admits only these concrete
327
+ // variable names, not arbitrary environment variables.
328
+ const gitConfigEnv = Object.keys(commitAttributionEnv || {})
329
+ .filter((name) => /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/.test(name));
330
+ const gitConfigKeySet = Object.entries(commitAttributionEnv || {})
331
+ .filter(([name]) => /^GIT_CONFIG_KEY_\d+$/.test(name))
332
+ .map(([name, value]) => `${toml(name)}=${toml(value)}`);
306
333
  const filesystem = [
307
334
  '":minimal"="read"',
308
335
  '"~/.commonly"="deny"',
@@ -313,16 +340,42 @@ const publicPermissionProfileFlags = (mode) => {
313
340
  '"~/.config"="deny"',
314
341
  '"/private/tmp"="deny"',
315
342
  `":workspace_roots"={"."="${workspaceAccess}",".commonly/**"="deny",".codex/**"="deny","*.env"="deny","*/*.env"="deny","*/*/*.env"="deny"}`,
343
+ ...(commitHooksPath ? [`${toml(commitHooksPath)}="read"`] : []),
344
+ ...originalHookPaths.map((hookPath) => `${toml(hookPath)}="read"`),
316
345
  ].join(',');
346
+ const shellEnvironment = [
347
+ 'PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_*',
348
+ ...(commitHooksPath ? [
349
+ ...gitConfigEnv,
350
+ 'COMMONLY_AGENT_GIT_CONFIG_INDEX',
351
+ 'COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT',
352
+ 'COMMONLY_AGENT_HOOKS_PATH',
353
+ 'COMMONLY_AGENT_ORIGINAL_HOOKS_PATH',
354
+ 'COMMONLY_AGENT_SEAT_NAME',
355
+ 'COMMONLY_AGENT_SEAT_ID',
356
+ 'COMMONLY_AGENT_ADAPTER',
357
+ 'COMMONLY_AGENT_MODEL',
358
+ 'COMMONLY_AGENT_EFFORT',
359
+ ] : []),
360
+ ];
317
361
  return [
318
362
  '-c', `default_permissions=${toml(PUBLIC_PERMISSION_PROFILE)}`,
319
363
  '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.filesystem={${filesystem}}`,
320
364
  '-c', `permissions.${PUBLIC_PERMISSION_PROFILE}.network.enabled=false`,
321
365
  // The MCP launcher receives explicitly forwarded env_vars separately.
322
- // Model-generated shell commands inherit only a small non-secret core.
323
- '-c', 'shell_environment_policy.inherit="core"',
366
+ // `core` drops explicit GIT_CONFIG_* and hook metadata before include_only
367
+ // can admit them. When attribution is active, inherit all then constrain
368
+ // model shells to the exact allowlist below; keep Codex's default secret
369
+ // filters enabled.
370
+ '-c', `shell_environment_policy.inherit="${commitHooksPath ? 'all' : 'core'}"`,
324
371
  '-c', 'shell_environment_policy.ignore_default_excludes=false',
325
- '-c', 'shell_environment_policy.include_only=["PATH","HOME","TMPDIR","LANG","LC_*"]',
372
+ '-c', `shell_environment_policy.include_only=${JSON.stringify(shellEnvironment)}`,
373
+ // Codex's default name filter treats GIT_CONFIG_KEY_n as sensitive. Set
374
+ // only those key names after filtering; their corresponding values stay
375
+ // in the allowlisted environment instead of riding argv.
376
+ ...(gitConfigKeySet.length
377
+ ? ['-c', `shell_environment_policy.set={${gitConfigKeySet.join(',')}}`]
378
+ : []),
326
379
  ];
327
380
  };
328
381
 
@@ -337,6 +390,7 @@ const buildArgs = ({
337
390
  publicSandboxMode = null,
338
391
  model = null,
339
392
  effort = null,
393
+ commitAttributionEnv = null,
340
394
  }) => {
341
395
  const publicSandbox = publicSandboxMode !== null;
342
396
  // `--dangerously-bypass-approvals-and-sandbox` disables codex CLI's
@@ -356,7 +410,10 @@ const buildArgs = ({
356
410
  ? [
357
411
  '--ignore-user-config',
358
412
  '--ignore-rules',
359
- ...publicPermissionProfileFlags(publicSandboxMode),
413
+ ...publicPermissionProfileFlags(
414
+ publicSandboxMode,
415
+ commitAttributionEnv,
416
+ ),
360
417
  ]
361
418
  : ['--dangerously-bypass-approvals-and-sandbox'];
362
419
  const common = [
@@ -562,6 +619,7 @@ export default {
562
619
  publicSandboxMode,
563
620
  model: ctx.environment?.model,
564
621
  effort: ctx.environment?.effort,
622
+ commitAttributionEnv: ctx.commitAttributionEnv,
565
623
  });
566
624
  const childEnv = { ...(ctx.env || process.env), ...mcp.forwardedEnv };
567
625
  // Derived from the process environment, so the bootstrap export has to be
@@ -573,6 +631,9 @@ export default {
573
631
  credentialFile: credential?.path || null,
574
632
  keepsValue: mcp.forwardedEnv[CREDENTIAL_KEY] !== undefined,
575
633
  });
634
+ if (publicSandboxMode !== null && ctx.commitAttributionEnv) {
635
+ Object.assign(childEnv, ctx.commitAttributionEnv);
636
+ }
576
637
  if (publicSandboxMode !== null) {
577
638
  childEnv.CODEX_HOME = await preparePublicCodexHome(ctx);
578
639
  }
@@ -0,0 +1,201 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ accessSync,
5
+ constants,
6
+ mkdtempSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join, resolve } from 'node:path';
13
+ import { spawnSync } from 'node:child_process';
14
+
15
+ const GIT_HOOKS = [
16
+ 'applypatch-msg',
17
+ 'pre-applypatch',
18
+ 'post-applypatch',
19
+ 'pre-commit',
20
+ 'pre-merge-commit',
21
+ 'prepare-commit-msg',
22
+ 'commit-msg',
23
+ 'post-commit',
24
+ 'pre-rebase',
25
+ 'post-checkout',
26
+ 'post-merge',
27
+ 'pre-push',
28
+ 'pre-receive',
29
+ 'update',
30
+ 'proc-receive',
31
+ 'post-receive',
32
+ 'post-update',
33
+ 'reference-transaction',
34
+ 'push-to-checkout',
35
+ 'pre-auto-gc',
36
+ 'post-rewrite',
37
+ 'sendemail-validate',
38
+ 'fsmonitor-watchman',
39
+ 'p4-pre-submit',
40
+ 'p4-prepare-changelist',
41
+ 'p4-changelist',
42
+ 'p4-post-changelist',
43
+ 'post-index-change',
44
+ ];
45
+
46
+ const cleanSingleLine = (value, fallback) => {
47
+ let cleaned = '';
48
+ for (const character of String(value ?? '')) {
49
+ const codePoint = character.codePointAt(0);
50
+ cleaned += codePoint <= 0x1f || codePoint === 0x7f ? ' ' : character;
51
+ }
52
+ cleaned = cleaned
53
+ .replace(/[<>]/g, '')
54
+ .replace(/\s+/g, ' ')
55
+ .trim();
56
+ return cleaned || fallback;
57
+ };
58
+
59
+ // Use an email-local-part-safe reversible id. URI escapes introduce `%`, which
60
+ // Git does not accept in a co-author trailer address. Keep the reversible form
61
+ // while it fits the 64-character email local-part limit; longer identities use
62
+ // a stable hash so GitHub can still lift the co-author on squash.
63
+ const seatAddressId = (agentName, instanceId) => {
64
+ const identity = JSON.stringify([
65
+ cleanSingleLine(agentName, 'agent'),
66
+ cleanSingleLine(instanceId, 'default'),
67
+ ]);
68
+ const reversibleId = Buffer.from(identity).toString('base64url');
69
+ if (reversibleId.length <= 64) return reversibleId;
70
+ return `sha256-${createHash('sha256').update(identity).digest('base64url')}`;
71
+ };
72
+
73
+ const resolveOriginalHooksPath = (cwd, env) => {
74
+ const result = spawnSync(
75
+ 'git',
76
+ ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
77
+ { cwd, env, encoding: 'utf8' },
78
+ );
79
+ if (result.status !== 0 || !result.stdout.trim()) return null;
80
+ const hooksPath = resolve(result.stdout.trim());
81
+ try {
82
+ return statSync(hooksPath).isDirectory() ? hooksPath : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
88
+ const resolveGitConfigCount = (env) => {
89
+ const raw = env.GIT_CONFIG_COUNT ?? '0';
90
+ if (!/^(0|[1-9]\d*)$/.test(String(raw))) {
91
+ throw new Error(`cannot enable commit attribution: GIT_CONFIG_COUNT is invalid (${raw})`);
92
+ }
93
+ const count = Number(raw);
94
+ if (!Number.isSafeInteger(count)) {
95
+ throw new Error('cannot enable commit attribution: GIT_CONFIG_COUNT is out of range');
96
+ }
97
+ return count;
98
+ };
99
+
100
+ const wrapperHook = () => `#!/bin/sh
101
+ # Commonly CLI commit attribution. Preserve the repository's configured hooks.
102
+ hook_name=\${0##*/}
103
+ config_index=$COMMONLY_AGENT_GIT_CONFIG_INDEX
104
+ base_count=$COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT
105
+ case "$config_index:$base_count" in
106
+ *[!0-9:]*|:*) exit 1 ;;
107
+ esac
108
+ GIT_CONFIG_COUNT=$base_count
109
+ export GIT_CONFIG_COUNT
110
+ unset "GIT_CONFIG_KEY_$config_index" "GIT_CONFIG_VALUE_$config_index"
111
+
112
+ original_hooks=\${COMMONLY_AGENT_ORIGINAL_HOOKS_PATH:-}
113
+ if [ -n "$original_hooks" ] && [ "$original_hooks" != "$COMMONLY_AGENT_HOOKS_PATH" ]; then
114
+ original_hook="$original_hooks/$hook_name"
115
+ if [ -x "$original_hook" ]; then
116
+ "$original_hook" "$@"
117
+ hook_status=$?
118
+ if [ "$hook_status" -ne 0 ]; then
119
+ exit "$hook_status"
120
+ fi
121
+ fi
122
+ fi
123
+
124
+ if [ "$hook_name" = "prepare-commit-msg" ] && [ -n "$1" ]; then
125
+ git interpret-trailers --in-place --if-exists=addIfDifferent \\
126
+ --trailer "Co-authored-by: $COMMONLY_AGENT_SEAT_NAME <$COMMONLY_AGENT_SEAT_ID@agents.commonly.invalid>" \\
127
+ --trailer "Agent-Model: $COMMONLY_AGENT_ADAPTER/$COMMONLY_AGENT_MODEL/$COMMONLY_AGENT_EFFORT" \\
128
+ "$1"
129
+ exit $?
130
+ fi
131
+ exit 0
132
+ `;
133
+
134
+ /**
135
+ * Install a per-spawn Git hook proxy and return an environment for the CLI
136
+ * child. The proxy delegates every standard hook to the repository's original
137
+ * hook directory, then adds Commonly trailers after an existing
138
+ * prepare-commit-msg hook has had its say.
139
+ */
140
+ export const prepareCommitAttribution = ({
141
+ cwd,
142
+ env = process.env,
143
+ agentName,
144
+ displayName,
145
+ instanceId = 'default',
146
+ adapter,
147
+ model,
148
+ effort,
149
+ }) => {
150
+ const configIndex = resolveGitConfigCount(env);
151
+ const originalHooksPath = resolveOriginalHooksPath(cwd, env);
152
+ const hooksDirectory = mkdtempSync(join(tmpdir(), 'commonly-agent-hooks-'));
153
+ try {
154
+ const hookSource = wrapperHook();
155
+ for (const hookName of GIT_HOOKS) {
156
+ const hookPath = join(hooksDirectory, hookName);
157
+ writeFileSync(hookPath, hookSource, { encoding: 'utf8', mode: 0o700 });
158
+ accessSync(hookPath, constants.X_OK);
159
+ }
160
+ } catch (error) {
161
+ rmSync(hooksDirectory, { recursive: true, force: true });
162
+ throw error;
163
+ }
164
+
165
+ const safeAdapter = cleanSingleLine(adapter, 'unknown');
166
+ const metadataEnv = {
167
+ COMMONLY_AGENT_GIT_CONFIG_INDEX: String(configIndex),
168
+ COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: String(configIndex),
169
+ COMMONLY_AGENT_HOOKS_PATH: hooksDirectory,
170
+ ...(originalHooksPath ? { COMMONLY_AGENT_ORIGINAL_HOOKS_PATH: originalHooksPath } : {}),
171
+ COMMONLY_AGENT_SEAT_NAME: cleanSingleLine(displayName || agentName, 'agent'),
172
+ COMMONLY_AGENT_SEAT_ID: seatAddressId(agentName, instanceId),
173
+ COMMONLY_AGENT_ADAPTER: safeAdapter,
174
+ COMMONLY_AGENT_MODEL: cleanSingleLine(model, 'default'),
175
+ COMMONLY_AGENT_EFFORT: cleanSingleLine(effort, 'default'),
176
+ };
177
+ const attributionEnv = {
178
+ ...env,
179
+ GIT_CONFIG_COUNT: String(configIndex + 1),
180
+ [`GIT_CONFIG_KEY_${configIndex}`]: 'core.hooksPath',
181
+ [`GIT_CONFIG_VALUE_${configIndex}`]: hooksDirectory,
182
+ ...metadataEnv,
183
+ };
184
+ const sandboxEnv = {
185
+ ...metadataEnv,
186
+ GIT_CONFIG_COUNT: '1',
187
+ GIT_CONFIG_KEY_0: 'core.hooksPath',
188
+ GIT_CONFIG_VALUE_0: hooksDirectory,
189
+ COMMONLY_AGENT_GIT_CONFIG_INDEX: '0',
190
+ COMMONLY_AGENT_GIT_CONFIG_BASE_COUNT: '0',
191
+ };
192
+
193
+ return {
194
+ env: attributionEnv,
195
+ sandboxEnv,
196
+ hooksDirectory,
197
+ cleanup: () => rmSync(hooksDirectory, { recursive: true, force: true }),
198
+ };
199
+ };
200
+
201
+ export const supportedGitHooks = () => [...GIT_HOOKS];
package/src/lib/inbox.js CHANGED
@@ -24,20 +24,30 @@
24
24
  * `list` prints both, labelled, so neither command has to be discovered by
25
25
  * failing first.
26
26
  *
27
- * The cursor is a TIMESTAMP PLUS the ids delivered at exactly that timestamp,
28
- * compared against `createdAt` — the only field on an item that never moves. The
29
- * queue's ORDER moves as items are acknowledged (priority, then newest first),
27
+ * The cursor is a TIMESTAMP PLUS the ids delivered INSIDE A WINDOW that ends at
28
+ * it, compared against `createdAt` — the only field on an item that never moves.
29
+ * The queue's ORDER moves as items are acknowledged (priority, then newest first),
30
30
  * so an offset cursor would skip rows; `createdAt` cannot.
31
31
  *
32
- * The ids are not decoration. A bare `ms > since` cursor loses any row that
33
- * shares its millisecond with the cursor and was inserted after the cursor was
34
- * written: the next run compares `ms > since` (false), nothing prints it, and no
35
- * later run can see it either, because the cursor never moves. That row is gone
36
- * silently and forever — the one failure this queue (576 open items, none ever
37
- * read) cannot afford. So the cursor carries the ids it already delivered at its
38
- * own millisecond and re-prints only the ones it did not: `ms > sinceMs ||
39
- * (ms === sinceMs && !delivered.has(id))`. A legacy plain-timestamp cursor parses
40
- * with an EMPTY set, which degrades toward re-printing rather than skipping.
32
+ * The window is what makes the cursor safe, and the ids are not decoration. A row
33
+ * is stamped from the WRITER's clock at the moment of the write and becomes
34
+ * visible only when that write commits, so a row can carry a stamp before the
35
+ * cursor and appear after the read that wrote it — and it is not late, it is
36
+ * simply invisible to a reader who resumes at the cursor. Starting a read W
37
+ * behind the cursor (`--window`, default 60s) makes that row visible on the next
38
+ * tick; the ids already delivered inside that window are then dropped, so the
39
+ * re-read prints only what the caller has not seen. A bare `ms > since` cursor
40
+ * loses such a row silently and forever — the one failure this queue (576 open
41
+ * items, none ever read) cannot afford, which is why the recoverable direction
42
+ * (re-printing, deduped by `attentionItemId`) is always the one taken here. A
43
+ * legacy plain-timestamp cursor parses with an EMPTY set, which degrades toward
44
+ * re-printing rather than skipping.
45
+ *
46
+ * The window is NOT a guess about clock skew alone: W must exceed the write's
47
+ * commit latency PLUS the writer's clock offset, and a caller that sets it to 0
48
+ * gets the exact-cursor behaviour (resume at the cursor, dedupe only the ids
49
+ * stamped with it). The ids are pruned to the window on every write, so the
50
+ * cursor file stays the size of one window of deliveries instead of growing.
41
51
  *
42
52
  * THE GENERAL RULE, which both cursor bugs above are instances of: a resume
43
53
  * cursor is valid only for the query that wrote it. `--kind` is a DIFFERENT
@@ -168,6 +178,24 @@ export const parseCursor = (value) => {
168
178
  };
169
179
  };
170
180
 
181
+ /**
182
+ * `--window <seconds>`: how far behind the cursor a read starts. The default is
183
+ * the spec's 60s — the margin over in-cluster write latency and clock offset —
184
+ * and 0 restores the exact-cursor behaviour. Refused rather than clamped: a
185
+ * negative or unreadable window would silently change which rows a read can see,
186
+ * and that is the failure this whole module is built to avoid.
187
+ */
188
+ export const DEFAULT_WINDOW_MS = 60 * 1000;
189
+
190
+ export const parseWindowMs = (value, fallback = DEFAULT_WINDOW_MS) => {
191
+ if (value === undefined || value === null || value === '') return fallback;
192
+ const seconds = Number(String(value).trim());
193
+ if (!Number.isFinite(seconds) || seconds < 0) {
194
+ throw new InboxRefusal(`--window must be a number of seconds >= 0, not ${value}.`);
195
+ }
196
+ return Math.round(seconds * 1000);
197
+ };
198
+
171
199
  /** A missing cursor file is "nothing seen yet", not an error. */
172
200
  export const readCursorFile = (filePath) => {
173
201
  if (!filePath) return null;
@@ -217,28 +245,46 @@ export const cursorKeyOf = (item) => String(item?.attentionItemId || item?.id ||
217
245
  /**
218
246
  * An item with an unparseable `createdAt` is KEPT under a cursor: dropping it
219
247
  * would lose the row silently, and re-printing is the recoverable error. The same
220
- * argument decides the millisecond boundary — see the module header.
248
+ * argument decides the boundary — see the module header.
249
+ *
250
+ * `seenIds` is the set delivered inside the window and is dropped BY ID, whatever
251
+ * the item's timestamp: the window exists to re-serve those rows to the server
252
+ * call, and the id is the only thing that can say "the caller already saw this
253
+ * one". `sinceMs` is the window's own start and is inclusive, matching the
254
+ * route's bound, so a row stamped exactly at it still prints.
221
255
  */
222
256
  export const filterItems = (items, { kind = null, sinceMs = null, seenIds = [] } = {}) => {
223
257
  const delivered = seenIds instanceof Set ? seenIds : new Set(seenIds);
224
258
  return (items || []).filter((item) => {
225
259
  if (kind && item?.kind !== kind) return false;
260
+ if (delivered.has(cursorKeyOf(item))) return false;
226
261
  if (sinceMs === null) return true;
227
262
  const ms = itemCreatedAtMs(item);
228
263
  if (ms === null) return true;
229
- if (ms > sinceMs) return true;
230
- return ms === sinceMs && !delivered.has(cursorKeyOf(item));
264
+ return ms >= sinceMs;
231
265
  });
232
266
  };
233
267
 
234
268
  /**
235
- * Newest timestamp delivered, so the next call resumes exactly where this one
236
- * ended — plus the ids delivered AT that timestamp, which are what make the
237
- * boundary safe to resume from. Those ids are carried forward only while the
238
- * timestamp stays put; once it moves, they can never match again (the check needs
239
- * `ms === sinceMs`) and carrying them would grow the file for nothing.
269
+ * Newest timestamp this query has covered — the previous cursor's stamp or the
270
+ * newest item printed, whichever is later — so the next call resumes from W behind
271
+ * it, plus every id delivered at or after that window's start, which is what the
272
+ * next read's window will re-serve and must therefore not print twice.
273
+ *
274
+ * The max is taken over the PRINTED items, not the server's `nextSince`, because
275
+ * `--kind` is filtered on this side: the server's window may hold rows of other
276
+ * kinds that this read will never print, and advancing the cursor onto them would
277
+ * move the high-water mark past a row of THIS kind that a later page has not
278
+ * delivered yet. When nothing printed, the cursor stays where it was — the
279
+ * fallback — so a quiet tick does not move the mark at all.
280
+ *
281
+ * The set is recomputed from the window plus the ids the previous cursor had not
282
+ * yet aged out of it, and both halves are load-bearing: this read's items are
283
+ * post-dedupe, so a row it dropped is absent from them and would be forgotten
284
+ * without the carry. The prune is what bounds the file at one window of
285
+ * deliveries instead of a list of everything ever printed.
240
286
  */
241
- export const nextCursorFrom = (items, fallback = null, kind = null) => {
287
+ export const nextCursorFrom = (items, fallback = null, kind = null, windowMs = DEFAULT_WINDOW_MS) => {
242
288
  let max = null;
243
289
  for (const item of items || []) {
244
290
  const ms = itemCreatedAtMs(item);
@@ -246,15 +292,33 @@ export const nextCursorFrom = (items, fallback = null, kind = null) => {
246
292
  if (max === null || ms > max) max = ms;
247
293
  }
248
294
  if (max === null) return fallback;
249
- const at = new Date(max).toISOString();
250
- const boundary = (items || []).filter((item) => itemCreatedAtMs(item) === max)
251
- .map(cursorKeyOf).filter(Boolean);
252
- const carried = fallback && fallback.ms === max ? (fallback.ids || []) : [];
295
+ // A high-water mark: the newest item printed, or the cursor that came in,
296
+ // whichever is later. A row printed from inside the window is older than the
297
+ // mark that delimited that window, and letting it drag the mark back would move
298
+ // the next read's `since` back with it — reopening ground this query has already
299
+ // covered and moving the prune cutoff that bounds the id set.
300
+ const mark = fallback && Number.isFinite(fallback.ms) && fallback.ms > max ? fallback.ms : max;
301
+ const at = new Date(mark).toISOString();
302
+ const cutoff = mark - windowMs;
303
+ const inWindow = (items || []).filter((item) => {
304
+ const ms = itemCreatedAtMs(item);
305
+ return ms !== null && ms >= cutoff;
306
+ }).map(cursorKeyOf).filter(Boolean);
307
+ // The ids from the previous cursor are NOT dead weight, and dropping them is a
308
+ // measurable bug: `items` is what this read SELECTED, so a row delivered last
309
+ // tick has already been filtered out of it, and the set built from `items` alone
310
+ // would forget it — then the read after that would print it again, and the
311
+ // dedupe would alternate every tick. They are carried only while the previous
312
+ // cursor's own stamp is still inside this window; past that the next read's
313
+ // `since` is later than every one of them, the server can never re-serve them,
314
+ // and keeping them would grow the file for nothing.
315
+ const carried = fallback && Number.isFinite(fallback.ms) && fallback.ms >= cutoff
316
+ ? (fallback.ids || []) : [];
253
317
  // The kind is the CURRENT read's, never inherited: the cursor describes the
254
318
  // query that just ran, and a mismatch with the file's own kind is what the
255
319
  // command refuses on before it makes a request.
256
320
  return {
257
- at, ms: max, ids: [...new Set([...carried, ...boundary])], kind: kind || null,
321
+ at, ms: mark, ids: [...new Set([...carried, ...inWindow])], kind: kind || null,
258
322
  };
259
323
  };
260
324
 
@@ -371,21 +435,31 @@ export const createInboxRequest = ({
371
435
  * that reports `hasMore: true` beside an empty page would otherwise spin
372
436
  * forever, and one that lies about `count` would page past the end.
373
437
  */
374
- export const fetchQueue = async (request, { podId } = {}) => {
438
+ export const fetchQueue = async (request, { podId, since } = {}) => {
375
439
  const items = [];
376
440
  let meta = {};
377
441
  let offset = 0;
378
442
  for (;;) {
379
443
  // eslint-disable-next-line no-await-in-loop
380
444
  const page = await request('/api/activity/decision-queue', {
381
- params: { limit: API_PAGE_LIMIT, offset, ...(podId ? { podId } : {}) },
445
+ params: {
446
+ limit: API_PAGE_LIMIT,
447
+ offset,
448
+ ...(podId ? { podId } : {}),
449
+ // Only sent when there is a cursor: a first read is asking for everything
450
+ // the account has, and a bound there would silently hide its older half.
451
+ ...(since ? { since } : {}),
452
+ },
382
453
  });
383
454
  const batch = Array.isArray(page?.items) ? page.items : [];
384
455
  items.push(...batch);
385
456
  meta = page || {};
386
457
  if (!meta.hasMore) break;
387
458
  if (batch.length === 0) break;
388
- if (Number.isInteger(meta.count) && items.length >= meta.count) break;
459
+ // A bounded call reports the WHOLE open set in `count` and the window in
460
+ // `windowCount`, so stopping on `count` would page past the window's end.
461
+ const reported = since && Number.isInteger(meta.windowCount) ? meta.windowCount : meta.count;
462
+ if (Number.isInteger(reported) && items.length >= reported) break;
389
463
  offset += batch.length;
390
464
  }
391
465
  return { items, meta };