@awebai/oats 0.22.1 → 0.22.3
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/README.md +10 -3
- package/bin/oats.mjs +302 -19
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +211 -6
- package/capabilities/oats-aweb/injects/aweb.md +10 -3
- package/capabilities/oats-aweb/oats.json +40 -7
- package/capabilities/oats-okf/agents/memory-harvest/AGENTS.md +3 -1
- package/capabilities/oats-okf/bin/oats-okf.mjs +125 -9
- package/capabilities/oats-okf/injects/okf.md +7 -0
- package/capabilities/oats-okf/oats.json +2 -2
- package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
- package/docs/capability-manifest.schema.json +41 -0
- package/docs/execution-targets.md +210 -0
- package/docs/implementation.md +14 -1
- package/docs/integrations.md +36 -0
- package/docs/migration-from-oas.md +1 -1
- package/docs/oats-config.schema.json +1 -0
- package/docs/operating-team-migration.md +269 -0
- package/docs/release-notes/v0.22.2.md +69 -0
- package/docs/release-notes/v0.22.3.md +80 -0
- package/docs/servers.md +145 -0
- package/docs/souls-and-instances.md +30 -3
- package/lib/core.mjs +528 -78
- package/lib/herdr.mjs +95 -0
- package/lib/servers.mjs +623 -0
- package/lib/session-input.mjs +78 -0
- package/lib/session-viewer.mjs +51 -0
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/bin/capture.mjs +59 -3
- package/packages/record/bin/recall.mjs +67 -1
- package/packages/record/lib/sessions-for-home.mjs +130 -0
- package/skills/oats/SKILL.md +6 -2
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Host-local terminal transport. Delivery policy belongs to the caller. */
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { inspectHerdr, inputHerdr, herdrCommand } from "./herdr.mjs";
|
|
6
|
+
|
|
7
|
+
const shells = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "login"]);
|
|
8
|
+
function tmux(target, args, { exec = execFileSync, input } = {}) {
|
|
9
|
+
return exec("tmux", ["-S", target.socket, ...args], {
|
|
10
|
+
encoding: "utf8", input, timeout: 10000, maxBuffer: 1024 * 1024,
|
|
11
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export function inspectSessionTarget(target, io) {
|
|
15
|
+
if (target.backend === "herdr") {
|
|
16
|
+
const s = inspectHerdr(target, io);
|
|
17
|
+
let state = s.present ? s.status : "stopped";
|
|
18
|
+
if (s.present && !s.agent) {
|
|
19
|
+
const processes = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info?.foreground_processes;
|
|
20
|
+
if (!Array.isArray(processes)) throw new Error("Herdr returned no process information");
|
|
21
|
+
if (!processes.length || processes.every((p) => shells.has(p.name))) state = "shell";
|
|
22
|
+
}
|
|
23
|
+
return { backend: "herdr", present: s.present, state, terminalId: target.terminalId };
|
|
24
|
+
}
|
|
25
|
+
let rows;
|
|
26
|
+
try {
|
|
27
|
+
rows = tmux(target, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_id}\t#{pane_dead}\t#{pane_current_command}\t#{pane_pid}"], io).trim().split("\n").filter(Boolean);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
// A missing window on a reachable server is absence. A lost socket is not.
|
|
30
|
+
if (/can't find (window|session)/i.test(String(e.stderr || ""))) return { backend: "tmux", present: false, state: "stopped" };
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
if (!rows.length) return { backend: "tmux", present: false, state: "stopped" };
|
|
34
|
+
if (rows.length !== 1) throw new Error("session window has multiple panes; choose an unsplit agent window");
|
|
35
|
+
const [paneId, dead, command, panePid] = rows[0].split("\t");
|
|
36
|
+
if (!/^%\d+$/.test(paneId) || !["0", "1"].includes(dead)) throw new Error("invalid tmux pane response");
|
|
37
|
+
let state = dead === "1" ? "stopped" : "unknown";
|
|
38
|
+
if (dead === "0" && shells.has(command)) {
|
|
39
|
+
// macOS tmux can report the wrapper shell while the harness is its child.
|
|
40
|
+
// Only a shell with no non-shell descendants is a fallback prompt.
|
|
41
|
+
if (!/^\d+$/.test(panePid)) throw new Error("tmux returned no pane process id");
|
|
42
|
+
const output = (io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,comm="], {
|
|
43
|
+
encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"],
|
|
44
|
+
});
|
|
45
|
+
const processes = output.split("\n").map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean);
|
|
46
|
+
const descendants = new Set([panePid]);
|
|
47
|
+
let active = false;
|
|
48
|
+
for (let changed = true; changed;) {
|
|
49
|
+
changed = false;
|
|
50
|
+
for (const [, pid, parent, name] of processes) {
|
|
51
|
+
if (descendants.has(parent) && !descendants.has(pid)) {
|
|
52
|
+
descendants.add(pid); changed = true;
|
|
53
|
+
if (!shells.has(basename(name).replace(/^-/, ""))) active = true;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
state = active ? "unknown" : "shell";
|
|
58
|
+
}
|
|
59
|
+
return { backend: "tmux", present: dead === "0", state, paneId };
|
|
60
|
+
}
|
|
61
|
+
export function inputSessionTarget(target, text, io) {
|
|
62
|
+
const state = inspectSessionTarget(target, io);
|
|
63
|
+
if (!state.present || state.state === "shell") throw new Error(`cannot submit input: session is ${state.state}`);
|
|
64
|
+
if (target.backend === "herdr") inputHerdr(target, text, io);
|
|
65
|
+
else {
|
|
66
|
+
// Bracketed paste preserves multiline input as one user message. No text
|
|
67
|
+
// is evaluated by a shell or interpreted as tmux key names.
|
|
68
|
+
const buffer = `oats-${randomUUID()}`;
|
|
69
|
+
try {
|
|
70
|
+
tmux(target, ["load-buffer", "-b", buffer, "-"], { ...io, input: text });
|
|
71
|
+
tmux(target, ["paste-buffer", "-p", "-b", buffer, "-t", state.paneId], io);
|
|
72
|
+
tmux(target, ["send-keys", "-t", state.paneId, "Enter"], io);
|
|
73
|
+
} finally {
|
|
74
|
+
try { tmux(target, ["delete-buffer", "-b", buffer], io); } catch { /* already consumed or disconnected */ }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { ...state, submitted: true };
|
|
78
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Interactive host-local viewers. Closing a viewer leaves its agent alive. */
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { inspectSessionTarget } from "./session-input.mjs";
|
|
5
|
+
|
|
6
|
+
export function prepareSessionViewer(target, { exec = execFileSync } = {}) {
|
|
7
|
+
if (!inspectSessionTarget(target, { exec }).present) throw new Error("session no longer exists");
|
|
8
|
+
if (target.backend === "herdr") {
|
|
9
|
+
const env = { ...process.env, HERDR_SOCKET_PATH: target.socket };
|
|
10
|
+
delete env.HERDR_SESSION;
|
|
11
|
+
return { binary: target.binary, args: ["terminal", "attach", target.terminalId], env, cleanup() {} };
|
|
12
|
+
}
|
|
13
|
+
const run = (args) => exec("tmux", ["-S", target.socket, ...args], {
|
|
14
|
+
encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
|
|
15
|
+
}).trim();
|
|
16
|
+
const viewer = `oatsview-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
17
|
+
const cleanup = () => { try { run(["kill-session", "-t", `=${viewer}`]); } catch { /* already detached/ended */ } };
|
|
18
|
+
try {
|
|
19
|
+
const placeholder = run(["new-session", "-d", "-s", viewer, "-P", "-F", "#{window_id}"]);
|
|
20
|
+
if (!/^@\d+$/.test(placeholder)) throw new Error("tmux returned no viewer window id");
|
|
21
|
+
run(["link-window", "-s", `=${target.session}:=${target.window}`, "-t", `=${viewer}:`]);
|
|
22
|
+
run(["kill-window", "-t", placeholder]);
|
|
23
|
+
// One linked window prevents a stale viewer from selecting a sibling agent
|
|
24
|
+
// when this agent retires. Disable window navigation in the viewer only.
|
|
25
|
+
for (const name of ["prefix", "prefix2"]) run(["set-option", "-t", viewer, name, "None"]);
|
|
26
|
+
run(["set-option", "-t", viewer, "key-table", "oatsview-locked"]);
|
|
27
|
+
run(["unbind-key", "-a", "-q", "-T", "oatsview-locked"]);
|
|
28
|
+
run(["bind-key", "-T", "oatsview-locked", "WheelUpPane", "if-shell", "-F", "#{||:#{pane_in_mode},#{mouse_any_flag}}", "send-keys -M", "copy-mode -e; send-keys -M"]);
|
|
29
|
+
run(["set-option", "-t", viewer, "mouse", "on"]);
|
|
30
|
+
const env = { ...process.env }; delete env.TMUX;
|
|
31
|
+
return { binary: "tmux", args: ["-S", target.socket, "attach-session", "-t", `=${viewer}`], env, cleanup };
|
|
32
|
+
} catch (e) { cleanup(); throw e; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function attachSessionTarget(target) {
|
|
36
|
+
const viewer = prepareSessionViewer(target);
|
|
37
|
+
let child;
|
|
38
|
+
const stop = () => { viewer.cleanup(); child?.kill(); };
|
|
39
|
+
const signals = ["SIGHUP", "SIGTERM", "SIGINT"];
|
|
40
|
+
for (const sig of signals) process.on(sig, stop);
|
|
41
|
+
try {
|
|
42
|
+
return await new Promise((resolve, reject) => {
|
|
43
|
+
child = spawn(viewer.binary, viewer.args, { env: viewer.env, stdio: "inherit" });
|
|
44
|
+
child.once("error", reject);
|
|
45
|
+
child.once("exit", (code) => resolve(code ?? 1));
|
|
46
|
+
});
|
|
47
|
+
} finally {
|
|
48
|
+
for (const sig of signals) process.removeListener(sig, stop);
|
|
49
|
+
viewer.cleanup();
|
|
50
|
+
}
|
|
51
|
+
}
|
package/package-catalog.json
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
"packages": {
|
|
3
3
|
"oats.okf": {
|
|
4
4
|
"url": "https://github.com/awebai/oats-okf.git",
|
|
5
|
-
"ref": "v1.
|
|
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.
|
|
10
|
+
"ref": "v1.10.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.
|
|
3
|
+
"version": "0.22.3",
|
|
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",
|
|
@@ -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}` : "";
|
|
@@ -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
|
+
}
|
package/skills/oats/SKILL.md
CHANGED
|
@@ -98,8 +98,12 @@ soul. Never put secrets, user data, or volatile task details in an instance
|
|
|
98
98
|
name.
|
|
99
99
|
|
|
100
100
|
To self-retire, first finish memory/commit/reporting requirements, report final
|
|
101
|
-
status, then run `oats retire <own-instance> --self`.
|
|
102
|
-
|
|
101
|
+
status, then run `oats retire <own-instance> --self`. That returns at once and
|
|
102
|
+
a detached completion retires you a few seconds later exactly as an external
|
|
103
|
+
`oats retire` would (quiesce, preserve work, hooks, remove the home). If the
|
|
104
|
+
completion fails, your window stays, the failure shows in `oats status` with
|
|
105
|
+
the retry command, and an operator retries. Never retire merely to clean up;
|
|
106
|
+
retirement deletes the instance home.
|
|
103
107
|
|
|
104
108
|
## Canonical versus generated
|
|
105
109
|
|