@os-eco/overstory-cli 0.7.7 → 0.7.8

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.
@@ -21,8 +21,9 @@ export interface MetricsStore {
21
21
  purge(options: { all?: boolean; agent?: string }): number;
22
22
  /** Record a token usage snapshot for a running agent. */
23
23
  recordSnapshot(snapshot: TokenSnapshot): void;
24
- /** Get the most recent snapshot per active agent (one row per agent). */
25
- getLatestSnapshots(): TokenSnapshot[];
24
+ /** Get the most recent snapshot per active agent (one row per agent).
25
+ * When runId is provided, restricts to snapshots recorded for that run. */
26
+ getLatestSnapshots(runId?: string): TokenSnapshot[];
26
27
  /** Get the timestamp of the most recent snapshot for an agent, or null. */
27
28
  getLatestSnapshotTime(agentName: string): string | null;
28
29
  /** Delete snapshots matching criteria. Returns number of rows deleted. */
@@ -60,6 +61,7 @@ interface SnapshotRow {
60
61
  cache_creation_tokens: number;
61
62
  estimated_cost_usd: number | null;
62
63
  model_used: string | null;
64
+ run_id: string | null;
63
65
  created_at: string;
64
66
  }
65
67
 
@@ -94,6 +96,7 @@ CREATE TABLE IF NOT EXISTS token_snapshots (
94
96
  cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
95
97
  estimated_cost_usd REAL,
96
98
  model_used TEXT,
99
+ run_id TEXT,
97
100
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f','now'))
98
101
  )`;
99
102
 
@@ -136,6 +139,18 @@ function migrateRunIdColumn(db: Database): void {
136
139
  }
137
140
  }
138
141
 
142
+ /**
143
+ * Migrate an existing token_snapshots table to include the run_id column.
144
+ * Safe to call multiple times — only adds the column if missing.
145
+ */
146
+ function migrateSnapshotRunIdColumn(db: Database): void {
147
+ const rows = db.prepare("PRAGMA table_info(token_snapshots)").all() as Array<{ name: string }>;
148
+ const existingColumns = new Set(rows.map((r) => r.name));
149
+ if (!existingColumns.has("run_id")) {
150
+ db.exec("ALTER TABLE token_snapshots ADD COLUMN run_id TEXT");
151
+ }
152
+ }
153
+
139
154
  /**
140
155
  * Migrate an existing sessions table to include token columns.
141
156
  * Safe to call multiple times — only adds columns that are missing.
@@ -183,6 +198,7 @@ function rowToSnapshot(row: SnapshotRow): TokenSnapshot {
183
198
  cacheCreationTokens: row.cache_creation_tokens,
184
199
  estimatedCostUsd: row.estimated_cost_usd,
185
200
  modelUsed: row.model_used,
201
+ runId: row.run_id,
186
202
  createdAt: row.created_at,
187
203
  };
188
204
  }
@@ -210,6 +226,7 @@ export function createMetricsStore(dbPath: string): MetricsStore {
210
226
  migrateBeadIdToTaskId(db);
211
227
  migrateTokenColumns(db);
212
228
  migrateRunIdColumn(db);
229
+ migrateSnapshotRunIdColumn(db);
213
230
 
214
231
  // Prepare statements for all queries
215
232
  const insertStmt = db.prepare<
@@ -282,13 +299,14 @@ export function createMetricsStore(dbPath: string): MetricsStore {
282
299
  $cache_creation_tokens: number;
283
300
  $estimated_cost_usd: number | null;
284
301
  $model_used: string | null;
302
+ $run_id: string | null;
285
303
  $created_at: string;
286
304
  }
287
305
  >(`
288
306
  INSERT INTO token_snapshots
289
- (agent_name, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, estimated_cost_usd, model_used, created_at)
307
+ (agent_name, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, estimated_cost_usd, model_used, run_id, created_at)
290
308
  VALUES
291
- ($agent_name, $input_tokens, $output_tokens, $cache_read_tokens, $cache_creation_tokens, $estimated_cost_usd, $model_used, $created_at)
309
+ ($agent_name, $input_tokens, $output_tokens, $cache_read_tokens, $cache_creation_tokens, $estimated_cost_usd, $model_used, $run_id, $created_at)
292
310
  `);
293
311
 
294
312
  const latestSnapshotsStmt = db.prepare<SnapshotRow, Record<string, never>>(`
@@ -301,6 +319,18 @@ export function createMetricsStore(dbPath: string): MetricsStore {
301
319
  ) latest ON s.agent_name = latest.agent_name AND s.created_at = latest.max_created_at
302
320
  `);
303
321
 
322
+ const latestSnapshotsByRunStmt = db.prepare<SnapshotRow, { $run_id: string }>(`
323
+ SELECT s.*
324
+ FROM token_snapshots s
325
+ INNER JOIN (
326
+ SELECT agent_name, MAX(created_at) as max_created_at
327
+ FROM token_snapshots
328
+ WHERE run_id = $run_id
329
+ GROUP BY agent_name
330
+ ) latest ON s.agent_name = latest.agent_name AND s.created_at = latest.max_created_at
331
+ WHERE s.run_id = $run_id
332
+ `);
333
+
304
334
  const latestSnapshotTimeStmt = db.prepare<
305
335
  { created_at: string } | null,
306
336
  { $agent_name: string }
@@ -401,11 +431,16 @@ export function createMetricsStore(dbPath: string): MetricsStore {
401
431
  $cache_creation_tokens: snapshot.cacheCreationTokens,
402
432
  $estimated_cost_usd: snapshot.estimatedCostUsd,
403
433
  $model_used: snapshot.modelUsed,
434
+ $run_id: snapshot.runId,
404
435
  $created_at: snapshot.createdAt,
405
436
  });
406
437
  },
407
438
 
408
- getLatestSnapshots(): TokenSnapshot[] {
439
+ getLatestSnapshots(runId?: string): TokenSnapshot[] {
440
+ if (runId !== undefined) {
441
+ const rows = latestSnapshotsByRunStmt.all({ $run_id: runId });
442
+ return rows.map(rowToSnapshot);
443
+ }
409
444
  const rows = latestSnapshotsStmt.all({});
410
445
  return rows.map(rowToSnapshot);
411
446
  },
@@ -179,6 +179,7 @@ describe("SQL schema consistency", () => {
179
179
  "input_tokens",
180
180
  "model_used",
181
181
  "output_tokens",
182
+ "run_id",
182
183
  ].sort();
183
184
 
184
185
  expect(actual).toEqual(expected);
package/src/types.ts CHANGED
@@ -105,6 +105,13 @@ export interface OverstoryConfig {
105
105
  printCommand?: string;
106
106
  /** Pi runtime configuration for model alias expansion. */
107
107
  pi?: PiRuntimeConfig;
108
+ /**
109
+ * Delay in milliseconds between creating a tmux session and polling
110
+ * for TUI readiness. Gives slow shells (oh-my-zsh, starship, etc.)
111
+ * time to finish initializing before the agent command starts.
112
+ * Default: 0 (no delay).
113
+ */
114
+ shellInitDelayMs?: number;
108
115
  };
109
116
  }
110
117
 
@@ -435,6 +442,7 @@ export interface TokenSnapshot {
435
442
  estimatedCostUsd: number | null;
436
443
  modelUsed: string | null;
437
444
  createdAt: string;
445
+ runId: string | null;
438
446
  }
439
447
 
440
448
  // === Task Groups (Batch Coordination) ===
@@ -3,6 +3,7 @@ import { AgentError } from "../errors.ts";
3
3
  import type { ReadyState } from "../runtimes/types.ts";
4
4
  import {
5
5
  capturePaneContent,
6
+ checkSessionState,
6
7
  createSession,
7
8
  ensureTmuxAvailable,
8
9
  getDescendantPids,
@@ -792,6 +793,54 @@ describe("isSessionAlive", () => {
792
793
  });
793
794
  });
794
795
 
796
+ describe("checkSessionState", () => {
797
+ let spawnSpy: ReturnType<typeof spyOn>;
798
+
799
+ beforeEach(() => {
800
+ spawnSpy = spyOn(Bun, "spawn");
801
+ });
802
+
803
+ afterEach(() => {
804
+ spawnSpy.mockRestore();
805
+ });
806
+
807
+ test("returns alive when tmux has-session succeeds", async () => {
808
+ spawnSpy.mockReturnValue(mockSpawnResult("", "", 0));
809
+ const state = await checkSessionState("overstory-test-coordinator");
810
+ expect(state).toBe("alive");
811
+ });
812
+
813
+ test("returns no_server when tmux reports no server running", async () => {
814
+ spawnSpy.mockReturnValue(
815
+ mockSpawnResult("", "no server running on /tmp/tmux-1000/default\n", 1),
816
+ );
817
+ const state = await checkSessionState("overstory-test-coordinator");
818
+ expect(state).toBe("no_server");
819
+ });
820
+
821
+ test("returns no_server when tmux reports no sessions", async () => {
822
+ spawnSpy.mockReturnValue(mockSpawnResult("", "no sessions\n", 1));
823
+ const state = await checkSessionState("overstory-test-coordinator");
824
+ expect(state).toBe("no_server");
825
+ });
826
+
827
+ test("returns dead when session not found", async () => {
828
+ spawnSpy.mockReturnValue(
829
+ mockSpawnResult("", "can't find session: overstory-test-coordinator\n", 1),
830
+ );
831
+ const state = await checkSessionState("overstory-test-coordinator");
832
+ expect(state).toBe("dead");
833
+ });
834
+
835
+ test("returns dead for generic tmux failure", async () => {
836
+ spawnSpy.mockReturnValue(
837
+ mockSpawnResult("", "error connecting to /tmp/tmux-1000/default\n", 1),
838
+ );
839
+ const state = await checkSessionState("overstory-test-coordinator");
840
+ expect(state).toBe("dead");
841
+ });
842
+ });
843
+
795
844
  describe("sendKeys", () => {
796
845
  let spawnSpy: ReturnType<typeof spyOn>;
797
846
 
@@ -409,6 +409,39 @@ export async function isSessionAlive(name: string): Promise<boolean> {
409
409
  return exitCode === 0;
410
410
  }
411
411
 
412
+ /**
413
+ * Detailed session state for distinguishing failure modes.
414
+ *
415
+ * - `"alive"` -- tmux session exists and is reachable.
416
+ * - `"dead"` -- tmux server is running but the session does not exist.
417
+ * - `"no_server"` -- tmux server is not running at all.
418
+ */
419
+ export type SessionState = "alive" | "dead" | "no_server";
420
+
421
+ /**
422
+ * Check tmux session state with detailed failure mode reporting.
423
+ *
424
+ * Unlike `isSessionAlive()` which returns a simple boolean, this function
425
+ * distinguishes between three states:
426
+ * - `"alive"`: session exists -- the agent may still be running.
427
+ * - `"dead"`: tmux server is running but session is gone -- agent exited or was killed.
428
+ * - `"no_server"`: tmux server itself is not running -- all sessions are gone.
429
+ *
430
+ * Callers can use this to provide targeted error messages and decide whether
431
+ * stale session records should be cleaned up vs flagged as errors.
432
+ *
433
+ * @param name - Session name to check
434
+ * @returns The session state
435
+ */
436
+ export async function checkSessionState(name: string): Promise<SessionState> {
437
+ const { exitCode, stderr } = await runCommand(["tmux", "has-session", "-t", name]);
438
+ if (exitCode === 0) return "alive";
439
+ if (stderr.includes("no server running") || stderr.includes("no sessions")) {
440
+ return "no_server";
441
+ }
442
+ return "dead";
443
+ }
444
+
412
445
  /**
413
446
  * Capture the visible content of a tmux session's pane.
414
447
  *