@ouro.bot/cli 0.1.0-alpha.314 → 0.1.0-alpha.316

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/changelog.json CHANGED
@@ -1,6 +1,18 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.316",
6
+ "changes": [
7
+ "feat(daemon): unified progress TUI for ouro up lifecycle. New UpProgress accumulated-checklist renderer replaces disconnected writeStdout calls with a cohesive progress display showing completed phases with checkmarks and active phase with spinner. In-place ANSI overwrite in TTY mode, static lines in non-TTY (CI/pipes). Wired into daemon.up handler for all phases: update check, system setup, agent updates, bundle pruning, daemon start. Emits nerves events for each phase completion."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.315",
12
+ "changes": [
13
+ "fix(tui): fix startup TUI ANSI overwrite, daemon death detection, and log visibility. Resolves visual corruption from overlapping ANSI escape sequences, improves process death detection reliability, and ensures daemon log output is consistently visible during startup."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.314",
6
18
  "changes": [
@@ -75,6 +75,7 @@ const interactive_repair_1 = require("./interactive-repair");
75
75
  const agentic_repair_1 = require("./agentic-repair");
76
76
  const startup_tui_1 = require("./startup-tui");
77
77
  const stale_bundle_prune_1 = require("./stale-bundle-prune");
78
+ const up_progress_1 = require("./up-progress");
78
79
  // ── ensureDaemonRunning ──
79
80
  async function ensureDaemonRunning(deps) {
80
81
  const alive = await deps.checkSocketAlive(deps.socketPath);
@@ -118,24 +119,38 @@ async function ensureDaemonRunning(deps) {
118
119
  const stability = await (0, startup_tui_1.pollDaemonStartup)({
119
120
  sendCommand: deps.sendCommand,
120
121
  socketPath: deps.socketPath,
121
- writeStdout: deps.writeStdout,
122
+ daemonPid: started.pid ?? null,
123
+ /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
124
+ writeRaw: (text) => process.stdout.write(text),
122
125
  /* v8 ignore next -- thin wrapper: real Date.now() injected for testability @preserve */
123
126
  now: () => Date.now(),
124
127
  /* v8 ignore next -- thin wrapper: real setTimeout injected for testability @preserve */
125
128
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
126
- /* v8 ignore start -- daemon log tail: reads real filesystem, tested via deployment @preserve */
129
+ /* v8 ignore start -- daemon log tail + pid check: reads real filesystem, tested via deployment @preserve */
127
130
  readLatestDaemonEvent: () => {
128
131
  try {
129
- const agents = fs.readdirSync((0, identity_1.getAgentBundlesRoot)()).filter((d) => d.endsWith(".ouro"));
132
+ // The daemon writes structured events to daemon.ndjson in the first
133
+ // agent bundle's state/daemon/logs/ directory. Read the last line to
134
+ // surface what it's currently doing (e.g., "starting auto-start agents").
135
+ const bundlesRoot = (0, identity_1.getAgentBundlesRoot)();
136
+ if (!fs.existsSync(bundlesRoot))
137
+ return null;
138
+ const agents = fs.readdirSync(bundlesRoot).filter((d) => d.endsWith(".ouro"));
130
139
  for (const agent of agents) {
131
- const logPath = path.join((0, identity_1.getAgentBundlesRoot)(), agent, "state", "daemon", "logs", "daemon.ndjson");
140
+ const logPath = path.join(bundlesRoot, agent, "state", "daemon", "logs", "daemon.ndjson");
132
141
  if (!fs.existsSync(logPath))
133
142
  continue;
134
- const buf = Buffer.alloc(2048);
143
+ const stat = fs.statSync(logPath);
144
+ if (stat.size === 0)
145
+ continue;
146
+ // Only read logs from the last 30 seconds (daemon just started)
147
+ const mtime = stat.mtimeMs;
148
+ if (Date.now() - mtime > 30_000)
149
+ continue;
150
+ const buf = Buffer.alloc(4096);
135
151
  const fd = fs.openSync(logPath, "r");
136
- const stat = fs.fstatSync(fd);
137
- const readFrom = Math.max(0, stat.size - 2048);
138
- fs.readSync(fd, buf, 0, 2048, readFrom);
152
+ const readFrom = Math.max(0, stat.size - 4096);
153
+ fs.readSync(fd, buf, 0, 4096, readFrom);
139
154
  fs.closeSync(fd);
140
155
  const lines = buf.toString("utf-8").trim().split("\n").filter(Boolean);
141
156
  const last = lines[lines.length - 1];
@@ -800,19 +815,19 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
800
815
  }
801
816
  }
802
817
  const linkedVersionBeforeUp = deps.getCurrentCliVersion?.() ?? null;
818
+ const progress = new up_progress_1.UpProgress({ write: deps.writeStdout, isTTY: false });
803
819
  // ── versioned CLI update check ──
804
820
  if (deps.checkForCliUpdate) {
805
- deps.writeStdout("checking for updates...");
821
+ progress.startPhase("update check");
806
822
  let pendingReExec = false;
807
823
  try {
808
824
  const updateResult = await deps.checkForCliUpdate();
809
825
  if (updateResult.available && updateResult.latestVersion) {
810
- deps.writeStdout(`installing ${updateResult.latestVersion}...`);
811
826
  /* v8 ignore next -- fallback: getCurrentCliVersion always injected in tests @preserve */
812
827
  const currentVersion = linkedVersionBeforeUp ?? "unknown";
813
828
  await deps.installCliVersion(updateResult.latestVersion);
814
829
  deps.activateCliVersion(updateResult.latestVersion);
815
- deps.writeStdout(`ouro updated to ${updateResult.latestVersion} (was ${currentVersion})`);
830
+ progress.completePhase("update check", `installed ${updateResult.latestVersion}`);
816
831
  const changelogCommand = (0, ouro_version_manager_1.buildChangelogCommand)(currentVersion, updateResult.latestVersion);
817
832
  /* v8 ignore next -- buildChangelogCommand is non-null when an actual newer version is installed @preserve */
818
833
  if (changelogCommand) {
@@ -833,13 +848,16 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
833
848
  }
834
849
  /* v8 ignore stop */
835
850
  if (pendingReExec) {
851
+ progress.end();
836
852
  deps.reExecFromNewVersion(args);
837
853
  }
838
854
  else {
839
- deps.writeStdout("up to date.");
855
+ progress.completePhase("update check", "up to date");
840
856
  }
841
857
  }
858
+ progress.startPhase("system setup");
842
859
  await performSystemSetup(deps);
860
+ progress.completePhase("system setup");
843
861
  // Track whether we've already printed the "ouro updated to" message
844
862
  // this turn so the bundle-meta-fallback path below doesn't double-print.
845
863
  // There are three independent paths that can detect "the binary just
@@ -905,15 +923,18 @@ async function runOuroCli(args, deps = (0, cli_defaults_1.createDefaultOuroCliDe
905
923
  const to = updateSummary.updated[0].to;
906
924
  const fromStr = from ? ` (was ${from})` : "";
907
925
  const count = agents.length;
908
- deps.writeStdout(`updated ${count} agent${count === 1 ? "" : "s"} to runtime ${to}${fromStr}`);
926
+ progress.startPhase("agent updates");
927
+ progress.completePhase("agent updates", `${count} agent${count === 1 ? "" : "s"} to runtime ${to}${fromStr}`);
909
928
  }
910
929
  // ── stale bundle pruning ──
911
930
  const prunedBundles = (0, stale_bundle_prune_1.pruneStaleEphemeralBundles)({ bundlesRoot: deps.bundlesRoot });
912
- for (const name of prunedBundles) {
913
- deps.writeStdout(`pruned stale bundle: ${name}`);
931
+ if (prunedBundles.length > 0) {
932
+ progress.startPhase("bundle cleanup");
933
+ progress.completePhase("bundle cleanup", `pruned ${prunedBundles.length} stale bundle${prunedBundles.length === 1 ? "" : "s"}`);
914
934
  }
915
- deps.writeStdout("starting daemon...");
935
+ progress.startPhase("starting daemon");
916
936
  const daemonResult = await ensureDaemonRunning(deps);
937
+ progress.end();
917
938
  deps.writeStdout(daemonResult.message);
918
939
  // Interactive repair for degraded agents (Unit 5) — skipped by --no-repair (Unit 6)
919
940
  if (daemonResult.stability?.degraded && daemonResult.stability.degraded.length > 0) {
@@ -13,6 +13,7 @@
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.assessStability = assessStability;
15
15
  exports.renderStartupProgress = renderStartupProgress;
16
+ exports.renderWaitingForDaemon = renderWaitingForDaemon;
16
17
  exports.pollDaemonStartup = pollDaemonStartup;
17
18
  const cli_render_1 = require("./cli-render");
18
19
  const runtime_1 = require("../../nerves/runtime");
@@ -66,19 +67,13 @@ function assessStability(payload, now) {
66
67
  /**
67
68
  * Build an ANSI string for in-place terminal display during polling.
68
69
  * Uses cursor-up and line-clear escapes to overwrite previous output.
69
- *
70
- * @param payload Current daemon status
71
- * @param elapsed Milliseconds since polling started
72
- * @param prevLineCount Number of lines written in the previous render (0 on first)
73
70
  */
74
71
  function renderStartupProgress(payload, elapsed, prevLineCount = 0) {
75
72
  const frameIndex = Math.floor(elapsed / 100) % SPINNER_FRAMES.length;
76
73
  const spinner = SPINNER_FRAMES[frameIndex];
77
74
  const lines = [];
78
- // Header line
79
75
  const elapsedSec = (elapsed / 1000).toFixed(1);
80
76
  lines.push(`${spinner} ${BOLD}waiting for agents${RESET} ${DIM}(${elapsedSec}s)${RESET}`);
81
- // Per-worker status lines
82
77
  for (const worker of payload.workers) {
83
78
  const statusColor = worker.status === "running" ? GREEN
84
79
  : worker.status === "crashed" ? RED
@@ -86,7 +81,27 @@ function renderStartupProgress(payload, elapsed, prevLineCount = 0) {
86
81
  const statusText = `${statusColor}${worker.status}${RESET}`;
87
82
  lines.push(` ${worker.agent}/${worker.worker}: ${statusText}`);
88
83
  }
89
- // Build output with cursor-up to overwrite previous lines
84
+ let output = "";
85
+ if (prevLineCount > 0) {
86
+ output += `\x1b[${prevLineCount}A`;
87
+ }
88
+ for (const line of lines) {
89
+ output += `\x1b[2K${line}\n`;
90
+ }
91
+ return output;
92
+ }
93
+ /**
94
+ * Render a pre-socket status line showing what the daemon is doing.
95
+ */
96
+ function renderWaitingForDaemon(elapsed, latestEvent, prevLineCount = 0) {
97
+ const elapsedSec = (elapsed / 1000).toFixed(1);
98
+ const frameIndex = Math.floor(elapsed / 100) % SPINNER_FRAMES.length;
99
+ const spinner = SPINNER_FRAMES[frameIndex];
100
+ const lines = [];
101
+ lines.push(`${spinner} ${BOLD}waiting for daemon${RESET} ${DIM}(${elapsedSec}s)${RESET}`);
102
+ if (latestEvent) {
103
+ lines.push(` ${DIM}${latestEvent}${RESET}`);
104
+ }
90
105
  let output = "";
91
106
  if (prevLineCount > 0) {
92
107
  output += `\x1b[${prevLineCount}A`;
@@ -119,15 +134,19 @@ function renderFinalSummary(result) {
119
134
  /**
120
135
  * Poll the daemon's status socket until all agents are stable or definitively
121
136
  * failed, rendering real-time progress to the terminal.
137
+ *
138
+ * Detects daemon process death: if the spawned PID is no longer alive and the
139
+ * socket never came up, reports the failure immediately instead of spinning.
122
140
  */
123
141
  async function pollDaemonStartup(deps) {
124
142
  const startTime = deps.now();
125
143
  let prevLineCount = 0;
144
+ const isAlive = deps.isProcessAlive ?? defaultIsProcessAlive;
126
145
  (0, runtime_1.emitNervesEvent)({
127
146
  component: "daemon",
128
147
  event: "daemon.startup_poll_start",
129
148
  message: "beginning startup stability polling",
130
- meta: { socketPath: deps.socketPath },
149
+ meta: { socketPath: deps.socketPath, daemonPid: deps.daemonPid },
131
150
  });
132
151
  while (true) {
133
152
  const now = deps.now();
@@ -138,39 +157,47 @@ async function pollDaemonStartup(deps) {
138
157
  payload = (0, cli_render_1.parseStatusPayload)(response.data);
139
158
  }
140
159
  catch {
141
- // Socket not yet available — show what the daemon is doing from its log
142
- const elapsedSec = (elapsed / 1000).toFixed(1);
143
- const frameIndex = Math.floor(elapsed / 100) % SPINNER_FRAMES.length;
144
- const spinner = SPINNER_FRAMES[frameIndex];
145
- const latestEvent = deps.readLatestDaemonEvent?.() ?? null;
146
- const lines = [];
147
- lines.push(`${spinner} ${BOLD}waiting for daemon${RESET} ${DIM}(${elapsedSec}s)${RESET}`);
148
- if (latestEvent) {
149
- lines.push(` ${DIM}${latestEvent}${RESET}`);
150
- }
151
- let output = "";
152
- if (prevLineCount > 0) {
153
- output += `\x1b[${prevLineCount}A`;
154
- }
155
- for (const line of lines) {
156
- output += `\x1b[2K${line}\n`;
160
+ // Socket not yet available — check if the daemon process is still alive
161
+ if (deps.daemonPid !== null && !isAlive(deps.daemonPid)) {
162
+ const latestEvent = deps.readLatestDaemonEvent?.() ?? null;
163
+ const errorMsg = latestEvent ?? "daemon process died during startup";
164
+ (0, runtime_1.emitNervesEvent)({
165
+ level: "error",
166
+ component: "daemon",
167
+ event: "daemon.startup_process_died",
168
+ message: "daemon process died before socket came up",
169
+ meta: { pid: deps.daemonPid, lastEvent: latestEvent },
170
+ });
171
+ // Clear the waiting line
172
+ if (prevLineCount > 0) {
173
+ let clear = `\x1b[${prevLineCount}A`;
174
+ for (let i = 0; i < prevLineCount; i++)
175
+ clear += `\x1b[2K\n`;
176
+ deps.writeRaw(clear);
177
+ }
178
+ return {
179
+ stable: [],
180
+ degraded: [{ agent: "daemon", errorReason: errorMsg, fixHint: "check daemon logs or run `ouro doctor`" }],
181
+ };
157
182
  }
158
- deps.writeStdout(output);
159
- prevLineCount = lines.length;
183
+ // Show what the daemon is doing from its log
184
+ const latestEvent = deps.readLatestDaemonEvent?.() ?? null;
185
+ const output = renderWaitingForDaemon(elapsed, latestEvent, prevLineCount);
186
+ deps.writeRaw(output);
187
+ prevLineCount = latestEvent ? 2 : 1;
160
188
  }
161
189
  if (payload) {
162
190
  const output = renderStartupProgress(payload, elapsed, prevLineCount);
163
- deps.writeStdout(output);
164
- prevLineCount = payload.workers.length + 1; // header + per-worker lines
191
+ deps.writeRaw(output);
192
+ prevLineCount = payload.workers.length + 1;
165
193
  const assessment = assessStability(payload, now);
166
194
  if (assessment.resolved) {
167
195
  const result = {
168
196
  stable: assessment.stable,
169
197
  degraded: assessment.degraded,
170
198
  };
171
- // Clear progress lines and render final summary
172
199
  const summary = renderFinalSummary(result);
173
- deps.writeStdout(summary);
200
+ deps.writeRaw(summary);
174
201
  (0, runtime_1.emitNervesEvent)({
175
202
  component: "daemon",
176
203
  event: "daemon.startup_poll_end",
@@ -187,3 +214,14 @@ async function pollDaemonStartup(deps) {
187
214
  await deps.sleep(POLL_INTERVAL_MS);
188
215
  }
189
216
  }
217
+ /* v8 ignore start -- process liveness check: uses real process.kill(0), tested via deployment @preserve */
218
+ function defaultIsProcessAlive(pid) {
219
+ try {
220
+ process.kill(pid, 0);
221
+ return true;
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ }
227
+ /* v8 ignore stop */
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ /**
3
+ * UpProgress — accumulated-checklist progress renderer for `ouro up`.
4
+ *
5
+ * Displays completed phases with checkmarks, the current phase with a
6
+ * spinner and elapsed time, and pending phases as plain text. Uses ANSI
7
+ * cursor control for in-place overwriting in TTY mode, and falls back to
8
+ * static line-per-phase output in non-TTY mode.
9
+ *
10
+ * The caller drives animation by calling `render(now)` on a setInterval.
11
+ * This module owns no timers.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.UpProgress = void 0;
15
+ const runtime_1 = require("../../nerves/runtime");
16
+ // ── ANSI constants (shared with startup-tui.ts pattern) ──
17
+ const SPINNER_FRAMES = "\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F";
18
+ const RESET = "\x1b[0m";
19
+ const BOLD = "\x1b[1m";
20
+ const DIM = "\x1b[2m";
21
+ const GREEN = "\x1b[38;2;46;204;64m";
22
+ // ── UpProgress class ──
23
+ class UpProgress {
24
+ write;
25
+ isTTY;
26
+ completed = [];
27
+ currentPhase = null;
28
+ prevLineCount = 0;
29
+ ended = false;
30
+ constructor(options) {
31
+ /* v8 ignore next -- thin wrapper: raw process.stdout.write for ANSI cursor control @preserve */
32
+ this.write = options?.write ?? ((text) => process.stdout.write(text));
33
+ /* v8 ignore next -- thin wrapper: real isTTY check injected for testability @preserve */
34
+ this.isTTY = options?.isTTY ?? (process.stdout.isTTY === true);
35
+ }
36
+ /**
37
+ * Begin a new phase with spinner. If a phase is already active, it is
38
+ * auto-completed (no detail text).
39
+ */
40
+ startPhase(label) {
41
+ if (this.currentPhase) {
42
+ this.completePhase(this.currentPhase.label);
43
+ }
44
+ this.currentPhase = { label, startedAt: Date.now() };
45
+ }
46
+ /**
47
+ * Mark the current phase as done. In non-TTY mode, immediately writes
48
+ * a static line. Emits a nerves event for observability.
49
+ */
50
+ completePhase(label, detail) {
51
+ if (!this.currentPhase) {
52
+ return;
53
+ }
54
+ const elapsedMs = Date.now() - this.currentPhase.startedAt;
55
+ this.completed.push({ label, detail });
56
+ this.currentPhase = null;
57
+ (0, runtime_1.emitNervesEvent)({
58
+ component: "daemon",
59
+ event: "daemon.up_phase_complete",
60
+ message: `phase complete: ${label}`,
61
+ meta: { phase: label, detail: detail ?? null, elapsedMs },
62
+ });
63
+ if (!this.isTTY) {
64
+ const detailStr = detail ? ` \u2014 ${detail}` : "";
65
+ this.write(` \u2713 ${label}${detailStr}\n`);
66
+ }
67
+ }
68
+ /**
69
+ * Build an ANSI string for in-place terminal display. Returns empty
70
+ * string in non-TTY mode (output is written eagerly in completePhase).
71
+ */
72
+ render(now) {
73
+ if (!this.isTTY) {
74
+ return "";
75
+ }
76
+ const lines = [];
77
+ // Completed phases
78
+ for (const phase of this.completed) {
79
+ const detailStr = phase.detail ? ` ${DIM}\u2014 ${phase.detail}${RESET}` : "";
80
+ lines.push(` ${GREEN}\u2713${RESET} ${phase.label}${detailStr}`);
81
+ }
82
+ // Current phase with spinner
83
+ if (this.currentPhase) {
84
+ const elapsed = now - this.currentPhase.startedAt;
85
+ const elapsedSec = (elapsed / 1000).toFixed(1);
86
+ const frameIndex = Math.floor(elapsed / 80) % SPINNER_FRAMES.length;
87
+ const spinner = SPINNER_FRAMES[frameIndex];
88
+ lines.push(` ${BOLD}${spinner}${RESET} ${this.currentPhase.label} ${DIM}(${elapsedSec}s)${RESET}`);
89
+ }
90
+ let output = "";
91
+ if (this.prevLineCount > 0) {
92
+ output += `\x1b[${this.prevLineCount}A`;
93
+ }
94
+ for (const line of lines) {
95
+ output += `\x1b[2K${line}\n`;
96
+ }
97
+ // Clear any leftover lines from previous render that are no longer needed
98
+ if (lines.length < this.prevLineCount) {
99
+ for (let i = 0; i < this.prevLineCount - lines.length; i++) {
100
+ output += `\x1b[2K\n`;
101
+ }
102
+ }
103
+ this.prevLineCount = lines.length;
104
+ return output;
105
+ }
106
+ /**
107
+ * Finalize the progress display. Clears the current phase (if any) and
108
+ * writes the final checklist state. Idempotent.
109
+ */
110
+ end() {
111
+ if (this.ended) {
112
+ return;
113
+ }
114
+ this.ended = true;
115
+ if (this.currentPhase) {
116
+ this.currentPhase = null;
117
+ }
118
+ if (this.isTTY) {
119
+ const output = this.render(Date.now());
120
+ if (output) {
121
+ this.write(output);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ exports.UpProgress = UpProgress;
@@ -68,6 +68,7 @@ const outlook_types_1 = require("./outlook-types");
68
68
  const presence_1 = require("../../arc/presence");
69
69
  const cares_1 = require("../../arc/cares");
70
70
  const episodes_1 = require("../../arc/episodes");
71
+ const session_events_1 = require("../session-events");
71
72
  const LIVE_TASK_STATUSES = ["processing", "validating", "collaborating", "blocked"];
72
73
  const ACTIVE_CODING_STATUSES = new Set(["spawning", "running", "waiting_input", "stalled"]);
73
74
  const BLOCKED_CODING_STATUSES = new Set(["waiting_input", "stalled"]);
@@ -378,14 +379,18 @@ function readOutlookMachineState(options = {}) {
378
379
  agents: agentStates.map(summarizeAgent),
379
380
  };
380
381
  }
382
+ // ---------------------------------------------------------------------------
383
+ // Session inventory — enumerate all sessions with summary metadata
384
+ // ---------------------------------------------------------------------------
381
385
  /* v8 ignore start — session envelope parsing utilities */
382
386
  function parseSessionUsage(raw) {
383
- if (!raw)
387
+ if (!raw || typeof raw !== "object")
384
388
  return null;
385
- const inputTokens = typeof raw.input_tokens === "number" ? raw.input_tokens : 0;
386
- const outputTokens = typeof raw.output_tokens === "number" ? raw.output_tokens : 0;
387
- const reasoningTokens = typeof raw.reasoning_tokens === "number" ? raw.reasoning_tokens : 0;
388
- const totalTokens = typeof raw.total_tokens === "number" ? raw.total_tokens : 0;
389
+ const record = raw;
390
+ const inputTokens = typeof record.input_tokens === "number" ? record.input_tokens : 0;
391
+ const outputTokens = typeof record.output_tokens === "number" ? record.output_tokens : 0;
392
+ const reasoningTokens = typeof record.reasoning_tokens === "number" ? record.reasoning_tokens : 0;
393
+ const totalTokens = typeof record.total_tokens === "number" ? record.total_tokens : 0;
389
394
  if (inputTokens === 0 && outputTokens === 0 && totalTokens === 0)
390
395
  return null;
391
396
  return { input_tokens: inputTokens, output_tokens: outputTokens, reasoning_tokens: reasoningTokens, total_tokens: totalTokens };
@@ -393,33 +398,29 @@ function parseSessionUsage(raw) {
393
398
  function parseSessionContinuity(raw) {
394
399
  if (!raw)
395
400
  return null;
396
- return {
397
- mustResolveBeforeHandoff: raw.mustResolveBeforeHandoff === true,
398
- lastFriendActivityAt: typeof raw.lastFriendActivityAt === "string" ? raw.lastFriendActivityAt : null,
401
+ if (typeof raw !== "object")
402
+ return null;
403
+ const record = raw;
404
+ const continuity = {
405
+ mustResolveBeforeHandoff: record.mustResolveBeforeHandoff === true,
406
+ lastFriendActivityAt: typeof record.lastFriendActivityAt === "string" ? record.lastFriendActivityAt : null,
399
407
  };
408
+ if (!continuity.mustResolveBeforeHandoff && continuity.lastFriendActivityAt === null)
409
+ return null;
410
+ return continuity;
400
411
  }
401
- function extractContent(message) {
402
- if (typeof message.content === "string")
403
- return message.content;
404
- return null;
412
+ function extractContent(event) {
413
+ if (!event)
414
+ return null;
415
+ const text = (0, session_events_1.extractEventText)(event);
416
+ return text.length > 0 ? text : null;
405
417
  }
406
- function extractToolCallNames(message) {
407
- const toolCalls = message.tool_calls;
408
- if (!Array.isArray(toolCalls))
418
+ function extractToolCallNames(event) {
419
+ if (!event)
409
420
  return [];
410
- return toolCalls
411
- .map((call) => {
412
- if (call && typeof call === "object" && "function" in call) {
413
- const fn = call.function;
414
- if (fn && typeof fn === "object" && "name" in fn) {
415
- return typeof fn.name === "string" ? fn.name : null;
416
- }
417
- }
418
- /* v8 ignore start */
419
- return null;
420
- /* v8 ignore stop */
421
- })
422
- .filter((name) => name !== null);
421
+ return event.toolCalls
422
+ .map((call) => call.function.name)
423
+ .filter((name) => typeof name === "string" && name.length > 0);
423
424
  }
424
425
  /* v8 ignore stop */
425
426
  function estimateTokenCount(messages) {
@@ -428,21 +429,13 @@ function estimateTokenCount(messages) {
428
429
  const content = extractContent(msg);
429
430
  if (content)
430
431
  charCount += content.length;
431
- const toolCalls = msg.tool_calls;
432
- if (Array.isArray(toolCalls)) {
433
- charCount += JSON.stringify(toolCalls).length;
434
- }
432
+ if (msg.toolCalls.length > 0)
433
+ charCount += JSON.stringify(msg.toolCalls).length;
435
434
  }
436
435
  return Math.ceil(charCount / 4);
437
436
  }
438
437
  function readSessionEnvelope(sessionPath) {
439
- try {
440
- const raw = fs.readFileSync(sessionPath, "utf-8");
441
- return JSON.parse(raw);
442
- }
443
- catch {
444
- return null;
445
- }
438
+ return (0, session_events_1.loadSessionEnvelopeFile)(sessionPath);
446
439
  }
447
440
  /* v8 ignore start — filesystem traversal with defensive isDirectory checks */
448
441
  function resolveAllSessionPaths(sessionsDir) {
@@ -516,18 +509,26 @@ function readSessionInventory(agentName, options = {}) {
516
509
  if (friendId === "self" && channel === "inner")
517
510
  continue;
518
511
  const envelope = readSessionEnvelope(sessionPath);
519
- const messages = Array.isArray(envelope?.messages) ? envelope.messages : [];
512
+ const events = envelope?.events ?? [];
513
+ const chronology = (0, session_events_1.deriveSessionChronology)(events);
520
514
  const lastUsage = parseSessionUsage(envelope?.lastUsage);
521
515
  const continuity = parseSessionContinuity(envelope?.state);
522
- const lastActivityAt = continuity?.lastFriendActivityAt ?? safeFileMtime(sessionPath) ?? now.toISOString();
523
- const activitySource = continuity?.lastFriendActivityAt ? "friend-facing" : "mtime-fallback";
524
- const userMessages = messages.filter((m) => m.role === "user");
525
- const assistantMessages = messages.filter((m) => m.role === "assistant");
516
+ const hasObservedEventTiming = events.some((event) => event.time.authoredAt !== null || event.time.observedAt !== null);
517
+ const lastActivityAt = hasObservedEventTiming
518
+ ? (chronology.lastActivityAt ?? continuity?.lastFriendActivityAt ?? safeFileMtime(sessionPath) ?? now.toISOString())
519
+ : (continuity?.lastFriendActivityAt ?? safeFileMtime(sessionPath) ?? now.toISOString());
520
+ const activitySource = hasObservedEventTiming && chronology.lastActivityAt
521
+ ? "event-timeline"
522
+ : continuity?.lastFriendActivityAt
523
+ ? "friend-facing"
524
+ : "mtime-fallback";
525
+ const userMessages = events.filter((m) => m.role === "user");
526
+ const assistantMessages = events.filter((m) => m.role === "assistant");
526
527
  const lastUser = userMessages.length > 0 ? userMessages[userMessages.length - 1] : null;
527
528
  const lastAssistant = assistantMessages.length > 0 ? assistantMessages[assistantMessages.length - 1] : null;
528
529
  const latestToolCallNames = [];
529
- for (let i = messages.length - 1; i >= 0; i--) {
530
- const names = extractToolCallNames(messages[i]);
530
+ for (let i = events.length - 1; i >= 0; i--) {
531
+ const names = extractToolCallNames(events[i]);
531
532
  if (names.length > 0) {
532
533
  latestToolCallNames.push(...names);
533
534
  break;
@@ -535,7 +536,7 @@ function readSessionInventory(agentName, options = {}) {
535
536
  }
536
537
  const friendName = resolveFriendName(friendsDir, friendId);
537
538
  // Derive reply state from message pattern
538
- const lastMsg = messages.length > 0 ? messages[messages.length - 1] : null;
539
+ const lastMsg = events.length > 0 ? events[events.length - 1] : null;
539
540
  const mustResolve = continuity?.mustResolveBeforeHandoff === true;
540
541
  let replyState = "idle";
541
542
  if (mustResolve) {
@@ -544,7 +545,7 @@ function readSessionInventory(agentName, options = {}) {
544
545
  else if (lastMsg?.role === "user") {
545
546
  replyState = "needs-reply";
546
547
  }
547
- else if (messages.length > 0) {
548
+ else if (events.length > 0) {
548
549
  replyState = "monitoring";
549
550
  }
550
551
  items.push({
@@ -556,13 +557,13 @@ function readSessionInventory(agentName, options = {}) {
556
557
  lastActivityAt,
557
558
  activitySource,
558
559
  replyState,
559
- messageCount: messages.length,
560
+ messageCount: events.length,
560
561
  lastUsage,
561
562
  continuity,
562
- latestUserExcerpt: truncateExcerpt(extractContent(lastUser ?? {})),
563
- latestAssistantExcerpt: truncateExcerpt(extractContent(lastAssistant ?? {})),
563
+ latestUserExcerpt: truncateExcerpt(extractContent(lastUser)),
564
+ latestAssistantExcerpt: truncateExcerpt(extractContent(lastAssistant)),
564
565
  latestToolCallNames,
565
- estimatedTokens: messages.length > 0 ? estimateTokenCount(messages) : null,
566
+ estimatedTokens: events.length > 0 ? estimateTokenCount(events) : null,
566
567
  });
567
568
  }
568
569
  items.sort((a, b) => b.lastActivityAt.localeCompare(a.lastActivityAt));
@@ -606,34 +607,10 @@ function readSessionTranscript(agentName, friendId, channel, key, options = {})
606
607
  const envelope = readSessionEnvelope(sessionPath);
607
608
  if (!envelope)
608
609
  return null;
609
- const rawMessages = Array.isArray(envelope.messages) ? envelope.messages : [];
610
+ const rawMessages = envelope.events;
610
611
  const friendsDir = path.join(agentRoot, "friends");
611
612
  const friendName = resolveFriendName(friendsDir, friendId);
612
- const messages = rawMessages.map((msg, index) => {
613
- const role = typeof msg.role === "string" ? msg.role : "user";
614
- const content = extractContent(msg);
615
- const result = { index, role, content };
616
- if (typeof msg.name === "string")
617
- result.name = msg.name;
618
- if (typeof msg.tool_call_id === "string")
619
- result.tool_call_id = msg.tool_call_id;
620
- if (Array.isArray(msg.tool_calls)) {
621
- result.tool_calls = msg.tool_calls
622
- .filter((call) => call != null && typeof call === "object")
623
- .map((call) => {
624
- const fn = call.function;
625
- return {
626
- id: typeof call.id === "string" ? call.id : "",
627
- type: typeof call.type === "string" ? call.type : "function",
628
- function: {
629
- name: typeof fn?.name === "string" ? fn.name : "unknown",
630
- arguments: typeof fn?.arguments === "string" ? fn.arguments : JSON.stringify(fn?.arguments ?? ""),
631
- },
632
- };
633
- });
634
- }
635
- return result;
636
- });
613
+ const messages = rawMessages;
637
614
  return {
638
615
  friendId,
639
616
  friendName,