iteration-cli 0.6.0 → 0.6.1

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/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ import { activateSource, connectedConfig, detectedSources, expandSource, loadMac
11
11
  import { importSourceHistory } from "./history-import.js";
12
12
  import { runCollector } from "./service.js";
13
13
  import { renderDetailedStatus, renderHome, safeWorkspaceName, } from "./status-view.js";
14
- import { installTurnSummary, turnSummaryApprovalHint, uninstallTurnSummary, } from "./turn-summary.js";
14
+ import { installTurnSummary, codexTurnSummaryApproval, uninstallTurnSummary, } from "./turn-summary.js";
15
15
  import { installLaunchAgent, launchAgentStatus, uninstallLaunchAgent, } from "./launch-agent.js";
16
16
  import { assertFreshCollectorDirectory, collectorSource, collectorSourceDirectory, collectorPaths, defaultCollectorDirectory, ensureStartableState, healthSnapshot, historyScanProgress, loadConfig, outboxSize, pendingEventIds, prepareSourceInitialState, saveConfig, saveState, validateCollectorEndpoint, } from "./storage.js";
17
17
  const DEFAULT_INGEST_ENDPOINT = "https://useiteration.com/api/traces/ingest";
@@ -135,7 +135,10 @@ function dataDirectory(source, values) {
135
135
  async function turnSummaryNotice(config) {
136
136
  try {
137
137
  await installTurnSummary(config);
138
- return ` A one-line friction summary now appears after agent turns in your repositories.${turnSummaryApprovalHint(config)}`;
138
+ if ((await codexTurnSummaryApproval(config)) === "needs_approval") {
139
+ return "\nOne more step: in Codex, type /hooks and approve Iteration to see your friction summary after each turn.";
140
+ }
141
+ return " A one-line friction summary now appears after agent turns in your repositories.";
139
142
  }
140
143
  catch (error) {
141
144
  return ` The turn summary could not be installed: ${error instanceof Error ? error.message : "unknown error"} Run iteration start ${collectorSource(config)} to retry it.`;
@@ -360,12 +363,13 @@ async function statusSnapshot(source, values) {
360
363
  const config = await loadConfig(paths);
361
364
  if (collectorSource(config) !== source)
362
365
  throw new Error(`This collector directory belongs to ${collectorSource(config)}.`);
363
- const [running, size, ids, scan, localHealth] = await Promise.all([
366
+ const [running, size, ids, scan, localHealth, approval] = await Promise.all([
364
367
  launchAgentStatus(source),
365
368
  outboxSize(paths),
366
369
  pendingEventIds(paths),
367
370
  historyScanProgress(collectorSourceDirectory(config), paths, source),
368
371
  healthSnapshot(paths),
372
+ codexTurnSummaryApproval(config),
369
373
  ]);
370
374
  const pendingEvents = size === 0 ? 0 : ids.size;
371
375
  const pendingBytes = ids.size === 0 ? 0 : size;
@@ -378,6 +382,7 @@ async function statusSnapshot(source, values) {
378
382
  pendingBytes,
379
383
  health: localHealth.health,
380
384
  healthAvailable: localHealth.kind === "available",
385
+ turnSummaryNeedsApproval: approval === "needs_approval",
381
386
  tracesUrl: new URL("/app/sessions", config.endpoint).toString(),
382
387
  logPath: path.join(os.homedir(), "Library", "Logs", `iteration-${source}-collector.error.log`),
383
388
  };
@@ -22,6 +22,7 @@ export type CliStatus = Readonly<{
22
22
  pendingBytes: number;
23
23
  health: CollectorHealth;
24
24
  healthAvailable: boolean;
25
+ turnSummaryNeedsApproval: boolean;
25
26
  tracesUrl: string;
26
27
  logPath: string;
27
28
  }>;
@@ -1,4 +1,5 @@
1
1
  import { collectorSourceLabel, } from "iteration-trace-contracts";
2
+ const CODEX_APPROVAL_STEP = "Friction summary is off: in Codex, type /hooks and approve Iteration.";
2
3
  function sourceName(source) {
3
4
  return collectorSourceLabel(source);
4
5
  }
@@ -197,6 +198,8 @@ export function renderHome(status, nowMs = Date.now(), source = "codex") {
197
198
  status.health.lastUploadAtMs !== null) {
198
199
  lines.push(`Last successful upload ${relativeTime(status.health.lastUploadAtMs, nowMs)}.`);
199
200
  }
201
+ if (status.turnSummaryNeedsApproval)
202
+ lines.push("", CODEX_APPROVAL_STEP);
200
203
  lines.push("", `Explore traces ${status.tracesUrl}`);
201
204
  return lines.join("\n");
202
205
  }
@@ -239,6 +242,8 @@ export function renderDetailedStatus(status, nowMs = Date.now(), source = "codex
239
242
  : "Local collector error";
240
243
  lines.push(`Last issue: ${label} (${relativeTime(failure.atMs, nowMs)})`, `Collector log: ${status.logPath}`);
241
244
  }
245
+ if (status.turnSummaryNeedsApproval)
246
+ lines.push(CODEX_APPROVAL_STEP);
242
247
  if (!status.running)
243
248
  lines.push(`Resume: iteration start ${source}`);
244
249
  lines.push(`Explore traces: ${status.tracesUrl}`);
@@ -18,4 +18,5 @@ export declare function refreshTurnSummaries(config: CollectorConfig, refreshedA
18
18
  directory: string;
19
19
  atMs: number;
20
20
  }>>, nowMs: number, fetcher?: typeof fetch, signal?: AbortSignal): Promise<number>;
21
- export declare function turnSummaryApprovalHint(config: CollectorConfig): string;
21
+ export type TurnSummaryApproval = "approved" | "needs_approval" | "not_installed";
22
+ export declare function codexTurnSummaryApproval(config: CollectorConfig): Promise<TurnSummaryApproval>;
@@ -1,8 +1,9 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { promises as fs } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { isLaunchDirectory, parseCollectorFrictionSummary, } from "iteration-trace-contracts";
4
5
  import * as z from "zod";
5
- import { collectorSource, ensurePrivateDirectory, } from "./storage.js";
6
+ import { ensurePrivateDirectory } from "./storage.js";
6
7
  const REFRESH_INTERVAL_MS = 5 * 60 * 1000;
7
8
  const REQUEST_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
8
9
  const MAX_REFRESHES_PER_PASS = 20;
@@ -295,8 +296,67 @@ export async function refreshTurnSummaries(config, refreshedAt, nowMs, fetcher =
295
296
  }
296
297
  return refreshed;
297
298
  }
298
- export function turnSummaryApprovalHint(config) {
299
- return collectorSource(config) === "codex"
300
- ? " Approve Iteration's turn summary once with /hooks in Codex."
301
- : "";
299
+ function tomlBasicString(value) {
300
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
301
+ }
302
+ // Codex runs a hook only after the developer trusts it in /hooks, and records
303
+ // that trust as a hash of the normalized hook. Recomputing the same hash tells
304
+ // whether Codex will run this exact entry; any other result keeps the reminder.
305
+ function codexHookHash(command) {
306
+ const identity = {
307
+ event_name: "stop",
308
+ hooks: [
309
+ {
310
+ async: false,
311
+ command,
312
+ timeout: HOOK_TIMEOUT_SECONDS,
313
+ type: "command",
314
+ },
315
+ ],
316
+ };
317
+ return `sha256:${createHash("sha256").update(JSON.stringify(identity)).digest("hex")}`;
318
+ }
319
+ function codexTrustedHash(configToml, key) {
320
+ const lines = configToml.split(/\r?\n/);
321
+ const header = `[hooks.state.${tomlBasicString(key)}]`;
322
+ const start = lines.findIndex((line) => line.trim() === header);
323
+ if (start < 0)
324
+ return null;
325
+ let trustedHash = null;
326
+ for (const line of lines.slice(start + 1)) {
327
+ const trimmed = line.trim();
328
+ if (trimmed.startsWith("["))
329
+ break;
330
+ if (/^enabled\s*=\s*false\b/.test(trimmed))
331
+ return null;
332
+ const match = /^trusted_hash\s*=\s*"(sha256:[0-9a-f]{64})"/.exec(trimmed);
333
+ if (match?.[1] !== undefined)
334
+ trustedHash = match[1];
335
+ }
336
+ return trustedHash;
337
+ }
338
+ export async function codexTurnSummaryApproval(config) {
339
+ if ("claudeHome" in config)
340
+ return "approved";
341
+ const filename = agentHookFile(config);
342
+ const { file } = await readHookFile(filename);
343
+ const command = hookCommand(turnSummaryPaths(config.dataDirectory));
344
+ const stop = file.hooks?.Stop ?? [];
345
+ for (const [groupIndex, group] of stop.entries()) {
346
+ const handlerIndex = group.hooks?.findIndex((hook) => hook.command === command) ?? -1;
347
+ if (handlerIndex < 0)
348
+ continue;
349
+ let configToml;
350
+ try {
351
+ configToml = await fs.readFile(path.join(config.codexHome, "config.toml"), "utf8");
352
+ }
353
+ catch (error) {
354
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
355
+ return "needs_approval";
356
+ throw new Error("Could not read the Codex config.", { cause: error });
357
+ }
358
+ const trusted = codexTrustedHash(configToml, `${filename}:stop:${groupIndex}:${handlerIndex}`);
359
+ return trusted === codexHookHash(command) ? "approved" : "needs_approval";
360
+ }
361
+ return "not_installed";
302
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iteration-cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",