@awebai/oats 0.22.0 → 0.22.2

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.
Files changed (60) hide show
  1. package/README.md +40 -50
  2. package/bin/oats.mjs +242 -22
  3. package/capabilities/oats-authoring/LICENSE +21 -0
  4. package/capabilities/oats-authoring/oats-package.json +11 -0
  5. package/capabilities/oats-authoring/oats.json +4 -4
  6. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
  7. package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
  8. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
  9. package/capabilities/oats-aweb/injects/aweb.md +4 -3
  10. package/capabilities/oats-aweb/oats.json +7 -7
  11. package/capabilities/oats-aweb/skills/LICENSE +21 -0
  12. package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
  13. package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
  14. package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
  15. package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
  16. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
  17. package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
  18. package/capabilities/oats-jira/oats.json +1 -1
  19. package/capabilities/oats-linear/oats.json +1 -1
  20. package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +3 -1
  21. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
  22. package/capabilities/oats-okf/bin/oats-okf.mjs +201 -54
  23. package/capabilities/oats-okf/injects/okf.md +7 -0
  24. package/capabilities/oats-okf/oats.json +5 -2
  25. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
  26. package/capabilities/oats-review/oats.json +1 -1
  27. package/docs/2026-09-03-architecture-proposal.md +642 -0
  28. package/docs/execution-targets.md +181 -0
  29. package/docs/first-team-demo.md +87 -0
  30. package/docs/first-team.md +179 -0
  31. package/docs/implementation.md +14 -1
  32. package/docs/integrations.md +83 -65
  33. package/docs/layers.md +356 -80
  34. package/docs/migration-from-oas.md +80 -116
  35. package/docs/oats-config.schema.json +1 -0
  36. package/docs/operating-team-migration.md +217 -0
  37. package/docs/release-notes/v0.22.1.md +106 -0
  38. package/docs/release-notes/v0.22.2.md +69 -0
  39. package/docs/servers.md +94 -0
  40. package/docs/souls-and-instances.md +30 -3
  41. package/lib/core.mjs +626 -415
  42. package/lib/herdr.mjs +95 -0
  43. package/lib/servers.mjs +436 -0
  44. package/lib/session-input.mjs +78 -0
  45. package/lib/session-viewer.mjs +51 -0
  46. package/package-catalog.json +2 -2
  47. package/package.json +1 -1
  48. package/packages/record/README.md +76 -16
  49. package/packages/record/bin/capture.mjs +59 -3
  50. package/packages/record/bin/recall.mjs +67 -1
  51. package/packages/record/docs/turn-record-sot.md +1 -1
  52. package/packages/record/lib/sessions-for-home.mjs +130 -0
  53. package/packages/record/lib/store.mjs +207 -43
  54. package/skills/oats/SKILL.md +6 -2
  55. package/capabilities/oats-aweb/package.json +0 -20
  56. package/capabilities/oats-jira/package.json +0 -25
  57. package/capabilities/oats-linear/README.md +0 -234
  58. package/capabilities/oats-linear/package.json +0 -29
  59. package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
  60. package/capabilities/oats-okf/package.json +0 -22
@@ -2,12 +2,12 @@
2
2
  "packages": {
3
3
  "oats.okf": {
4
4
  "url": "https://github.com/awebai/oats-okf.git",
5
- "ref": "v1.4.1",
5
+ "ref": "v1.5.0",
6
6
  "path": "oats-package"
7
7
  },
8
8
  "oats.aweb": {
9
9
  "url": "https://github.com/awebai/oats-aweb.git",
10
- "ref": "v1.8.0",
10
+ "ref": "v1.9.0",
11
11
  "path": "oats-package"
12
12
  },
13
13
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -6,12 +6,17 @@ contract is [`docs/turn-record-sot.md`](docs/turn-record-sot.md) in this
6
6
  package; the conformance vectors under `test/vectors/` pin the format
7
7
  (`node test/vectors/validate.mjs`, dependency-free).
8
8
 
9
- Everything is a signed turn in an append-only record. This package is the
10
- core: gathering every conversation, keeping it durably, and finding it
11
- again. The experimental tools that select and synthesize over the record
12
- (dress, spawn, segments, mind) live in `packages/experimental` of the oats
13
- repo and run as `oats experimental <cmd>`; they are deliberately not part
14
- of this package.
9
+ Turns in this append-only record are content-addressed. Native session turns
10
+ are not signed. Projected aweb mail and chat keep their original message
11
+ signatures verbatim.
12
+
13
+ After `turn-record setup` runs on a machine, this package gathers Claude Code,
14
+ Pi, and Codex transcripts plus aw client logs. It skips sources matched by the
15
+ local record's ignore list. The package keeps captured conversations durably
16
+ and finds them again. The experimental tools that select and synthesize over
17
+ the record (dress, spawn, segments, mind) live in `packages/experimental` of
18
+ the oats repo and run as `oats experimental <cmd>`. They are deliberately not
19
+ part of this package.
15
20
 
16
21
  - **`lib/canonical.mjs`** — canonical JSON (integers only in the core),
17
22
  `t1:` content ids, did:key Ed25519 verification. Byte-compatible with awid
@@ -126,16 +131,71 @@ non-prefix copy of a stream quarantines it loudly.
126
131
 
127
132
  ## Durability and concurrency
128
133
 
129
- Appends and merges run under a per-stream lockfile
130
- (`streams/<id>/.lock`, exclusive-create, stale after 30 s), because the
131
- torn-tail repair is a read-truncate-write sequence and hooks, watchers and
132
- manual passes can fire concurrently for the same owner. Sync tools may copy
133
- a `.lock` file; that cannot corrupt data, but a synced-in stale lock can
134
- delay a local `mergeStreamCopy` on that stream by up to the stale threshold
135
- (30 s), so excluding `.lock` from sync patterns is the right configuration. Journal writes fsync;
136
- note that on macOS `fsync(2)` does not guarantee media durability (that
137
- would need `F_FULLFSYNC`, which Node's fs API does not expose) — the
138
- guarantee is OS-crash-level, not power-loss-level.
134
+ Appends and merges run under a per-stream lockfile (`streams/<id>/.lock`),
135
+ because the torn-tail repair is a read-truncate-write sequence and hooks,
136
+ watchers and manual passes can fire concurrently for the same owner.
137
+
138
+ The lock carries an **owner token** — the holder's pid, its hostname, and a
139
+ random nonce — written by hard-linking a fully-written temp file into place,
140
+ so a lock that exists is always readable in full. Two guarantees rest on
141
+ that token:
142
+
143
+ - **A live holder is never stolen from, at any age.** Staleness is proven
144
+ from the holder's liveness, not from how long ago the lock was created.
145
+ A contender that finds the holder's pid alive on this host waits, and
146
+ fails with a timeout naming the holder — it does not reclaim. (The
147
+ earlier design judged a lock stale by age while the waiter's timeout
148
+ started when the contender arrived; a contender arriving late therefore
149
+ reclaimed a lock whose holder was still inside its critical section, and
150
+ both ran at once.)
151
+ - **A holder releases only its own lock.** Release unlinks `.lock` only
152
+ while it still carries the releaser's nonce, so a holder whose lock was
153
+ reclaimed cannot delete the replacement lock out from under its new
154
+ owner.
155
+
156
+ **Crash recovery is immediate, not timed**: a lock whose pid is gone from
157
+ this host is provably stale and is reclaimed at once, without waiting out
158
+ any threshold.
159
+
160
+ A lock that cannot be read at all (anything but "not there" — a permission
161
+ error, a directory in its place) is not retried: acquisition fails at once
162
+ with the underlying error code and the lock's path, because retrying cannot
163
+ clear such a condition. Everything else that fails to acquire — including a
164
+ lock judged stale whose removal keeps failing — is bounded by
165
+ `lockTimeoutMs` and reports why on timeout.
166
+
167
+ `lockStaleMs` (30 s by default) is the fallback for the two cases where
168
+ liveness cannot be checked here: a lock created on **another host** (file
169
+ sync can copy one in) and a lock with **no readable token** (written by an
170
+ older version of this package, or by hand). Those are still reclaimed by
171
+ age. `lockTimeoutMs` (10 s) is how long a contender waits; the two
172
+ thresholds are independent, and neither ordering of them is required or
173
+ assumed.
174
+
175
+ Honest limits:
176
+
177
+ - **Pid reuse across a reboot.** A lock left by a process whose pid was
178
+ later reused by an unrelated live process reads as held forever, and
179
+ contenders on that stream time out until it is deleted by hand. The
180
+ timeout message names the lock path and the recorded holder. This is the
181
+ chosen direction of failure: refusing to write is recoverable, writing
182
+ concurrently with a live holder is not.
183
+ - **Reclaim is not perfectly atomic.** A contender re-checks the lock's
184
+ identity (inode and mtime) immediately before removing it. That recheck
185
+ **detects** a replacement that landed while the old lock was being proven
186
+ stale — the common cascade, one contender reclaiming from whoever
187
+ reclaimed first — and the contender gives up and retries. It is a
188
+ detection, not an exclusion: a replacement landing in the window between
189
+ the recheck and the unlink is not seen and is removed. Nothing here
190
+ defends against that window.
191
+ - **Sync tools may copy a `.lock` file.** That cannot corrupt data, but a
192
+ synced-in lock from another host is judged by age, so it can delay a local
193
+ `mergeStreamCopy` on that stream by up to 30 s — excluding `.lock` from
194
+ sync patterns remains the right configuration.
195
+
196
+ Journal writes fsync; note that on macOS `fsync(2)` does not guarantee media
197
+ durability (that would need `F_FULLFSYNC`, which Node's fs API does not
198
+ expose) — the guarantee is OS-crash-level, not power-loss-level.
139
199
 
140
200
  ## Upgrading
141
201
 
@@ -14,11 +14,12 @@
14
14
 
15
15
  import { watch } from "node:fs";
16
16
  import { homedir, hostname } from "node:os";
17
- import { join } from "node:path";
17
+ import { dirname, join } from "node:path";
18
18
  import process from "node:process";
19
19
 
20
20
  import { RecordStore } from "../lib/store.mjs";
21
- import { captureAllSessions } from "../lib/capture-cc.mjs";
21
+ import { captureAllSessions, captureSessions } from "../lib/capture-cc.mjs";
22
+ import { sessionsForHome } from "../lib/sessions-for-home.mjs";
22
23
  import { SESSION_FORMATS } from "../lib/formats.mjs";
23
24
  import { captureAwLogs, defaultCommLogDir } from "../lib/capture-aw.mjs";
24
25
  import { RecordIndex } from "../lib/index-db.mjs";
@@ -41,7 +42,7 @@ function loadIgnoreOrExit(recordRoot) {
41
42
  // hostname-derived owner, which forked the whole record into a second owner
42
43
  // namespace: 571 duplicate journals from one typo. Parsing must refuse what
43
44
  // it does not understand before anything can be written.
44
- const VALUE_FLAGS = new Set(["root", "owner"]);
45
+ const VALUE_FLAGS = new Set(["root", "owner", "home"]);
45
46
  const BOOL_FLAGS = new Set([
46
47
  "watch",
47
48
  "status",
@@ -61,6 +62,12 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
61
62
  capture --watch pass now, then re-pass on filesystem change
62
63
  (debounced) and every 15 minutes regardless
63
64
  capture --status show store/stream summary, capture nothing
65
+ capture --home <dir> capture the sessions that ran inside <dir> (an
66
+ OATS instance home) and print them as JSON:
67
+ thread, stream, turn count, first/last turn id.
68
+ Tombstoned turns are never a boundary. Codex
69
+ keeps a day's rollouts in one directory, so the
70
+ pass captures that day; the list is filtered.
64
71
  capture --install-hint print the Claude Code hook snippet
65
72
  capture --help this text
66
73
  capture --quiet suppress per-pass progress
@@ -224,6 +231,55 @@ if (args.status) {
224
231
  process.exit(0);
225
232
  }
226
233
 
234
+ // One instance home: capture its own sessions and report them with exact
235
+ // sequence boundaries (first and last captured turn id), so a consumer such
236
+ // as the OKF harvester can name what it read without timestamps, which tie
237
+ // and which late capture appends behind. Output is JSON, always: this mode
238
+ // exists for programs.
239
+ if (args.home) {
240
+ warnOnStrangerOwner();
241
+ const ignore = loadIgnoreOrExit(root);
242
+ const unattributed = [];
243
+ const found = sessionsForHome(args.home, { onUnattributed: (source, path) => unattributed.push({ source, path }) });
244
+ const sessions = [];
245
+ const dirs = new Map(); // one capture pass per (format, directory)
246
+ for (const s of found) dirs.set(`${s.source}\0${dirname(s.path)}`, { format: s.source, dir: dirname(s.path) });
247
+ let appended = 0;
248
+ for (const { format, dir } of dirs.values()) {
249
+ appended += captureSessions(store, { owner, roots: [dir], format, ignore }).appended;
250
+ }
251
+ if (appended > 0 && !args["no-index"]) {
252
+ const index = new RecordIndex(store);
253
+ try {
254
+ index.update();
255
+ } finally {
256
+ index.close();
257
+ }
258
+ }
259
+ // A tombstoned turn is hidden everywhere; a boundary naming one would be
260
+ // refused by recall, so boundaries come from the visible turns only.
261
+ const claims = store.tombstoneClaims();
262
+ for (const s of found) {
263
+ const stream = `${owner}~${s.source}.${s.sessionId}`;
264
+ const turns = store.readStream(stream).filter((t) => !store.claimHides(claims, t));
265
+ if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
266
+ sessions.push({
267
+ thread: s.thread,
268
+ source: s.source,
269
+ sessionId: s.sessionId,
270
+ path: s.path,
271
+ cwd: s.cwd,
272
+ stream,
273
+ turns: turns.length,
274
+ firstTurnId: turns[0].id,
275
+ lastTurnId: turns[turns.length - 1].id,
276
+ lastTs: turns[turns.length - 1].ts,
277
+ });
278
+ }
279
+ console.log(JSON.stringify({ home: args.home, owner, appended, sessions, ...(unattributed.length ? { unattributed } : {}) }, null, 2));
280
+ process.exit(0);
281
+ }
282
+
227
283
  warnOnStrangerOwner();
228
284
  pass();
229
285
 
@@ -4,6 +4,13 @@
4
4
  // recall <query...> FTS5 query over mail, chat, sessions
5
5
  // recall --kind mail <query...> filter by kind (mail|chat|session|note)
6
6
  // recall --thread <thread> [query] filter/list by thread
7
+ // recall --thread <thread> --json the thread's turns in journal order,
8
+ // [--after <id>] [--until <id>] with extracted text; exact id bounds
9
+ // [--limit n] [--ids-only] (after is exclusive, until inclusive);
10
+ // --ids-only lists id, ts and the bytes
11
+ // each turn occupies in the --json output
12
+ // (text extracted but not emitted), so a
13
+ // consumer can size a window it will read
7
14
  // recall --from <name> <query...> filter by speaker
8
15
  // recall --limit N max results (default 20)
9
16
  // recall --show <turn-id> print one turn as JSON
@@ -22,7 +29,7 @@ function parseArgs(argv) {
22
29
  const args = { _: [] };
23
30
  for (let i = 0; i < argv.length; i++) {
24
31
  const a = argv[i];
25
- if (["--root", "--kind", "--thread", "--from", "--role", "--limit", "--show"].includes(a)) {
32
+ if (["--root", "--kind", "--thread", "--from", "--role", "--limit", "--show", "--after", "--until"].includes(a)) {
26
33
  args[a.slice(2)] = argv[++i];
27
34
  } else if (a.startsWith("--")) args[a.slice(2)] = true;
28
35
  else args._.push(a);
@@ -65,6 +72,57 @@ try {
65
72
  process.exit(2);
66
73
  }
67
74
 
75
+ // Thread turns straight from the journal, in capture SEQUENCE: the order a
76
+ // consumer can bound exactly with turn ids. Timestamps tie within a turn
77
+ // and late capture appends older stamps behind newer ones, so a window
78
+ // named by ids is the only one two passes agree on. after is exclusive,
79
+ // until inclusive; an unknown id is an error, never an empty answer.
80
+ if (args.json && args.thread && !query) {
81
+ // Session streams are not in readAll(), so hiddenIds() cannot see them:
82
+ // resolve tombstone claims once and test each session turn directly
83
+ // (store.mjs, tombstoneClaims). A redacted line must never reach the
84
+ // harvester, which promotes what it reads into a durable soul.
85
+ const claims = store.tombstoneClaims();
86
+ const turns = [];
87
+ const sessionStreams = store.sessionStreamsFor(args.thread);
88
+ for (const streamId of sessionStreams) {
89
+ for (const t of store.readStream(streamId)) if (!store.claimHides(claims, t)) turns.push(t);
90
+ }
91
+ if (!sessionStreams.length) {
92
+ // not a session thread (mail, chat, note): the bulk read has them
93
+ for (const { turn } of store.readAll().values()) if (turn.thread === args.thread && !store.claimHides(claims, turn)) turns.push(turn);
94
+ }
95
+ const ids = turns.map((t) => t.id);
96
+ let start = 0;
97
+ let end = turns.length;
98
+ if (args.after) {
99
+ const i = ids.indexOf(args.after);
100
+ if (i < 0) { console.error(`--after: no turn ${args.after} in thread ${args.thread}`); process.exit(1); }
101
+ start = i + 1;
102
+ }
103
+ if (args.until) {
104
+ const i = ids.indexOf(args.until);
105
+ if (i < 0) { console.error(`--until: no turn ${args.until} in thread ${args.thread}`); process.exit(1); }
106
+ end = i + 1;
107
+ }
108
+ const cap = args.limit ? Number(args.limit) : Infinity;
109
+ const stop = Math.min(end, start + cap);
110
+ const window = turns.slice(start, stop);
111
+ // A window is a bounded read: a consumer that plans one (the OKF
112
+ // harvester) sizes it with --ids-only first, then reads exactly that.
113
+ const out = window.map((t) => {
114
+ const docs = index.extractText(t);
115
+ const base = { id: t.id, ts: t.ts, thread: t.thread, kind: t.kind, source: t.provenance?.source ?? null };
116
+ const full = { ...base, text: docs.map((d) => ({ role: d.role, text: d.text })) };
117
+ // bytes = what this turn occupies in the pretty-printed --json answer,
118
+ // so a consumer's byte cap bounds what it will actually receive.
119
+ if (args["ids-only"]) return { ...base, bytes: Buffer.byteLength(JSON.stringify(full, null, 2), "utf8") + 8 };
120
+ return full;
121
+ });
122
+ console.log(JSON.stringify({ thread: args.thread, total: turns.length, from: start, to: stop, remaining: end - stop, turns: out }, null, 2));
123
+ process.exit(0);
124
+ }
125
+
68
126
  const limit = args.limit ? Number(args.limit) : 20;
69
127
  let rows;
70
128
  if (query) {
@@ -86,10 +144,18 @@ try {
86
144
  .all(args.thread, limit);
87
145
  }
88
146
 
147
+ if (rows.length === 0 && args.json) {
148
+ console.log("[]");
149
+ process.exit(0);
150
+ }
89
151
  if (rows.length === 0) {
90
152
  console.error("no matches");
91
153
  process.exit(1);
92
154
  }
155
+ if (args.json) {
156
+ console.log(JSON.stringify(rows, null, 2));
157
+ process.exit(0);
158
+ }
93
159
  for (const r of rows) {
94
160
  const where = r.loc ? ` @${r.loc}` : "";
95
161
  const to = r.to_name ? ` -> ${r.to_name}` : "";
@@ -8,7 +8,7 @@ aweb mail and chat messages into it. It is the contract implemented by the
8
8
  record tools (`capture`, `recall`, and the experimental tools over them); this
9
9
  package (`@awebai/turn-record`, in the oats repo) is the reference
10
10
  implementation, and the spec lives beside it on purpose: the record spans
11
- agent sessions from every harness, and the aweb server is one projected
11
+ agent sessions from supported harnesses, and the aweb server is one projected
12
12
  source, not the record's home. The architecture decision it implements is
13
13
  `2026-08-18-turn-record-and-tools.md` in the strategy repo
14
14
  (github.com/awebai/strategy); the identity and messaging contracts it builds
@@ -0,0 +1,130 @@
1
+ // sessionsForHome — the session transcripts one OATS instance home produced.
2
+ //
3
+ // Nothing in a captured session turn names the instance that ran it: turns
4
+ // carry owner, thread, kind and text. What every harness DOES record is the
5
+ // working directory the session started in — Claude Code on each line, pi in
6
+ // its session header, Codex in session_meta — and an OATS instance runs with
7
+ // its home as that directory. So "the instance's own sessions" is exactly
8
+ // "the session files whose recorded cwd is the home or a directory below it".
9
+ //
10
+ // The match is on canonical paths and is exact-or-descendant, never looser:
11
+ // a home is disposable and unique, so anything that ran inside it is the
12
+ // instance's own, and nothing outside it — not the parent workspace, not a
13
+ // sibling home — is ever swept in.
14
+
15
+ import { closeSync, openSync, readSync, realpathSync, statSync } from "node:fs";
16
+ import { resolve, sep } from "node:path";
17
+ import { StringDecoder } from "node:string_decoder";
18
+
19
+ import { SESSION_FORMATS } from "./formats.mjs";
20
+
21
+ // A session's first lines can be large (Claude Code queue operations and
22
+ // file-history snapshots run to 100 KB and more) and the first cwd-bearing
23
+ // line can sit past 100 KB of bookkeeping lines, so the scan is incremental:
24
+ // whole lines only, chunk by chunk, until the first cwd or the byte bound.
25
+ // A file whose bound is exhausted without a cwd is reported as unattributable
26
+ // through the optional `onUnattributed` hook, never silently dropped.
27
+ const CHUNK_BYTES = 64 * 1024;
28
+ export const CWD_SCAN_BOUND_BYTES = 8 * 1024 * 1024;
29
+
30
+ function* wholeLines(path, bound) {
31
+ const fd = openSync(path, "r");
32
+ try {
33
+ const buf = Buffer.alloc(CHUNK_BYTES);
34
+ const decoder = new StringDecoder("utf8"); // a multi-byte character may straddle two chunks
35
+ let carry = "";
36
+ let offset = 0;
37
+ while (offset < bound) {
38
+ const n = readSync(fd, buf, 0, Math.min(CHUNK_BYTES, bound - offset), offset);
39
+ if (n === 0) break;
40
+ offset += n;
41
+ carry += decoder.write(buf.subarray(0, n));
42
+ let nl;
43
+ while ((nl = carry.indexOf("\n")) >= 0) {
44
+ yield carry.slice(0, nl);
45
+ carry = carry.slice(nl + 1);
46
+ }
47
+ }
48
+ // The remainder is a fragment unless the file ended exactly there.
49
+ if (carry && offset < bound) yield carry;
50
+ } finally {
51
+ closeSync(fd);
52
+ }
53
+ }
54
+
55
+ function cwdOfLine(source, line) {
56
+ if (!line.trim()) return undefined;
57
+ let d;
58
+ try {
59
+ d = JSON.parse(line);
60
+ } catch {
61
+ return undefined; // a non-JSON native line
62
+ }
63
+ if (source === "cc" && typeof d.cwd === "string") return d.cwd;
64
+ if (source === "pi" && d.type === "session" && typeof d.cwd === "string") return d.cwd;
65
+ if (source === "codex" && d.type === "session_meta" && typeof d.payload?.cwd === "string") return d.payload.cwd;
66
+ return undefined;
67
+ }
68
+
69
+ /** The working directory a session file records, scanning whole lines from
70
+ * the start until the first one that carries it, or undefined when none
71
+ * does within `bound` bytes (unknown format, torn file, no cwd at all). */
72
+ export function sessionCwd(source, path, { bound = CWD_SCAN_BOUND_BYTES } = {}) {
73
+ try {
74
+ for (const line of wholeLines(path, bound)) {
75
+ const cwd = cwdOfLine(source, line);
76
+ if (cwd) return cwd;
77
+ }
78
+ } catch {
79
+ return undefined;
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ function canonical(p) {
85
+ try {
86
+ return realpathSync(p);
87
+ } catch {
88
+ return resolve(p); // a retired home's cwd no longer exists; compare the lexical path
89
+ }
90
+ }
91
+
92
+ function within(child, parent) {
93
+ return child === parent || child.startsWith(parent + sep);
94
+ }
95
+
96
+ /** Session files whose recorded cwd is `home` or below it, oldest first.
97
+ * `roots` may override the per-format search roots ({ cc, pi, codex }),
98
+ * otherwise each format's default roots under the current HOME are used.
99
+ * `onUnattributed(source, path)` is called for a file that carries no cwd
100
+ * within the scan bound, so a caller can report it instead of losing it.
101
+ * Each entry: { source, sessionId, thread, path, cwd, bytes, mtime }. */
102
+ export function sessionsForHome(home, { roots, onUnattributed, bound } = {}) {
103
+ const target = canonical(home);
104
+ const out = [];
105
+ for (const fmt of Object.values(SESSION_FORMATS)) {
106
+ const rs = roots?.[fmt.source] ?? fmt.defaultRoots();
107
+ for (const path of fmt.listFiles(rs)) {
108
+ const cwd = sessionCwd(fmt.source, path, bound ? { bound } : {});
109
+ if (!cwd) { if (onUnattributed) onUnattributed(fmt.source, path); continue; }
110
+ if (!within(canonical(cwd), target)) continue;
111
+ let stat;
112
+ try {
113
+ stat = statSync(path);
114
+ } catch {
115
+ continue; // vanished between listing and stat
116
+ }
117
+ const sessionId = fmt.sessionId(path);
118
+ out.push({
119
+ source: fmt.source,
120
+ sessionId,
121
+ thread: `${fmt.source}:session:${sessionId}`,
122
+ path,
123
+ cwd,
124
+ bytes: stat.size,
125
+ mtime: stat.mtime.toISOString(),
126
+ });
127
+ }
128
+ }
129
+ return out.sort((a, b) => a.mtime.localeCompare(b.mtime) || a.path.localeCompare(b.path));
130
+ }