@awebai/oats 0.22.2 → 0.22.4

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/lib/servers.mjs CHANGED
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { execFileSync } from "node:child_process";
20
+ import { createHash } from "node:crypto";
20
21
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
21
22
  import { homedir } from "node:os";
22
23
  import { join, resolve } from "node:path";
@@ -112,7 +113,16 @@ export function targetOf(server) {
112
113
  /** argv for the local ssh process that runs one remote oats command. `--`
113
114
  * ends ssh's own options so a host alias can never be read as one; the
114
115
  * remote command is one quoted string, as ssh requires. */
115
- export function sshArgv(target, oatsArgs) {
116
+ /** A stable key for a route target: two registrations may name the same
117
+ * host and workspace, and a registration may change; groups and guards key
118
+ * on the target, never on the registry id alone. */
119
+ export function targetKey(target) {
120
+ // Host and workspace only: the binary path (like --path and --label) is how
121
+ // to reach the same homes, not which homes; each snapshot keeps its own.
122
+ return createHash("sha256").update(`${target.sshHost}\0${target.workspace}`).digest("hex").slice(0, 12);
123
+ }
124
+
125
+ export function sshArgv(target, oatsArgs, { cwd } = {}) {
116
126
  // A non-interactive ssh command runs in the login shell's minimal PATH,
117
127
  // which rarely includes user-local tool directories (~/.local/bin, where
118
128
  // claude and pi commonly live). A registration may name directories to
@@ -122,7 +132,9 @@ export function sshArgv(target, oatsArgs) {
122
132
  // one and never touches the other.
123
133
  const dirs = target.path ? target.path.split(":").filter(Boolean).map((d) => (d.startsWith("~/") ? `"$HOME"${remoteQuote(d.slice(1))}` : remoteQuote(d))) : [];
124
134
  const prefix = dirs.length ? `PATH=${dirs.join(":")}:"$PATH" ` : "";
125
- const cmd = prefix + [target.oatsPath, ...oatsArgs].map(remoteQuote).join(" ");
135
+ // An optional working directory for commands that read their instance
136
+ // from cwd (oats okf harvest): a quoted cd, never a path from the caller.
137
+ const cmd = (cwd ? `cd ${remoteQuote(cwd)} && ` : "") + prefix + [target.oatsPath, ...oatsArgs].map(remoteQuote).join(" ");
126
138
  return ["ssh", ...SSH_OPTS, "--", target.sshHost, cmd];
127
139
  }
128
140
 
@@ -168,7 +180,7 @@ export function parseRemoteEnvelope(stdout) {
168
180
  * refused key, unknown host) surface as E_SSH with ssh's stderr. */
169
181
  export function runRemote(target, oatsArgs, io = {}) {
170
182
  const exec = io.execFileSync || execFileSync;
171
- const [bin, ...argv] = sshArgv(target, oatsArgs);
183
+ const [bin, ...argv] = sshArgv(target, oatsArgs, { cwd: io.cwd });
172
184
  let stdout = "";
173
185
  let stderr = "";
174
186
  let status = 0;
@@ -217,6 +229,7 @@ export function checkRemote(target, io = {}) {
217
229
  sessionBackends: list("sessionBackends", []),
218
230
  launchOptions: list("launchOptions", []),
219
231
  remote: list("remote", []),
232
+ features: list("features", []),
220
233
  advertised: Array.isArray(probe.runtimes),
221
234
  };
222
235
  }
@@ -316,6 +329,17 @@ export function removeSnapshot(serverId, instance) {
316
329
  rmSync(snapshotPath(serverId, instance), { force: true });
317
330
  }
318
331
 
332
+ /** Drop a saved route on purpose: the remote instance is gone (retired on the
333
+ * host, home removed, host rebuilt) and nothing routed can remove the
334
+ * snapshot any more. The operator's decision, never automatic. */
335
+ export function forgetSnapshot(serverId, instance) {
336
+ const p = snapshotPath(serverId, instance);
337
+ if (!existsSync(p)) throw serverError("E_SNAPSHOT_UNKNOWN", `no saved route for ${instance} through server ${serverId} (oats server roster --json)`);
338
+ const snap = readSnapshot(serverId, instance);
339
+ rmSync(p, { force: true });
340
+ return snap;
341
+ }
342
+
319
343
  export function listSnapshots(serverId) {
320
344
  const dir = join(REMOTE_SNAPSHOT_DIR(), serverId);
321
345
  if (!existsSync(dir)) return [];
@@ -344,7 +368,7 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
344
368
  // A retirement may outlive its registration: the snapshot taken at spawn
345
369
  // is the route, and it is consulted before the registry is required.
346
370
  const instanceArg = oatsArgs.find((a) => !a.startsWith("--"));
347
- const snap = cmd === "retire" && instanceArg ? readSnapshot(serverId, instanceArg) : undefined;
371
+ const snap = ["retire", "harvest"].includes(cmd) && instanceArg ? readSnapshot(serverId, instanceArg) : undefined;
348
372
  let server = io.server;
349
373
  if (!server) {
350
374
  try { server = getServer(serverId); }
@@ -355,7 +379,22 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
355
379
  const json = (args) => (args.includes("--json") ? args : [...args, "--json"]);
356
380
 
357
381
  if (cmd === "spawn") {
382
+ // A registration edited to another target must not overwrite the saved
383
+ // routes of instances spawned through the old one: the next snapshot for
384
+ // the same name would silently retarget them. Refuse, naming the remedy.
385
+ const priorTargets = listSnapshots(serverId).filter((s) => s.target && targetKey(s.target) !== targetKey(target));
386
+ if (priorTargets.length) {
387
+ const old = priorTargets[0].target;
388
+ throw serverError("E_ROUTE_CHANGED", `server ${serverId} now points at ${target.sshHost}:${target.workspace}, but ${priorTargets.length} saved route${priorTargets.length === 1 ? "" : "s"} for it (${priorTargets.map((s) => s.instance).join(", ")}) target ${old.sshHost}:${old.workspace}; spawning would overwrite them. Keep the old registration and add a new server id for the new target, or retire those instances first`);
389
+ }
358
390
  const remote = checkRemote(target, io);
391
+ // A saved route is keyed by name under its server id. An explicit name
392
+ // that already has one here would overwrite that route on success: refuse
393
+ // before the mutation (the roster shows the existing route; forget or
394
+ // retire it first).
395
+ const ii = oatsArgs.indexOf("--instance");
396
+ const explicitName = ii >= 0 && oatsArgs[ii + 1] && !oatsArgs[ii + 1].startsWith("--") ? oatsArgs[ii + 1] : undefined;
397
+ if (explicitName && readSnapshot(serverId, explicitName)) throw serverError("E_ROUTE_EXISTS", `a saved route for ${explicitName} through server ${serverId} already exists (${readSnapshot(serverId, explicitName).home}); retire it (oats retire ${explicitName} --server ${serverId}) or drop it (oats server forget ${serverId} --instance ${explicitName}) before spawning that name again`);
359
398
  // The remote roster: the soul's runtime default for the support check,
360
399
  // and the remote agents root for the snapshot (the kernel's spawn result
361
400
  // does not carry it, and guessing it from the workspace would be wrong
@@ -365,6 +404,18 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
365
404
  checkRemoteSupport(remote, target, oatsArgs, status.result);
366
405
  const { envelope, stderr } = runRemote(target, json(withScope(["spawn", ...oatsArgs])), io);
367
406
  if (envelope.ok && envelope.result?.instance) {
407
+ // A generated name can still collide with a saved route of another
408
+ // soul on the same host (dev --purpose foo-1 vs dev-foo --purpose 1).
409
+ // The existing route is never overwritten: the new instance is
410
+ // reported without a saved route, to be retired on the host by --home.
411
+ // An existing route is replaced only by a result that provably names
412
+ // the same home; absent data is never permission to overwrite it.
413
+ const prior = readSnapshot(serverId, envelope.result.instance);
414
+ const sameAsPrior = prior && prior.home && envelope.result.home && resolve(prior.home) === resolve(envelope.result.home);
415
+ if (prior && !sameAsPrior) {
416
+ const warnings = [...(envelope.result.warnings || []), `no saved route: ${envelope.result.instance} already names ${prior.home} through ${serverId}; this instance (${envelope.result.home}) is not managed from here, retire it on the host with oats retire ${envelope.result.instance} --home ${envelope.result.home}`];
417
+ return { envelope: { ...envelope, result: { ...envelope.result, server: serverId, target, snapshot: null, routeConflict: { instance: envelope.result.instance, existingHome: prior.home }, warnings } }, stderr };
418
+ }
368
419
  const snapshot = writeSnapshot(serverId, target, remote, { ...envelope.result, agentsRoot: envelope.result.agentsRoot || status.result.root });
369
420
  return { envelope: { ...envelope, result: { ...envelope.result, server: serverId, target, snapshot: snapshotPath(serverId, envelope.result.instance) } }, stderr, snapshot };
370
421
  }
@@ -372,8 +423,29 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
372
423
  }
373
424
  if (cmd === "retire") {
374
425
  const name = instanceArg;
375
- checkRemote(target, io); // a mutation: version and envelope first, like spawn
376
- const { envelope, stderr } = runRemote(target, json(withScope(["retire", ...oatsArgs])), io);
426
+ const remote = checkRemote(target, io); // a mutation: version and envelope first, like spawn
427
+ // The saved route knows WHICH home was spawned through it; a remote kernel
428
+ // that resolves --home retires that one, never a same-named twin. A
429
+ // caller's explicit --home must be that same home: anything else would
430
+ // retire a sibling and then drop this instance's saved route.
431
+ const hi = oatsArgs.indexOf("--home");
432
+ const explicitHome = hi >= 0 && oatsArgs[hi + 1] && !oatsArgs[hi + 1].startsWith("--") ? oatsArgs[hi + 1] : undefined;
433
+ if (explicitHome && snap?.home && resolve(explicitHome) !== resolve(snap.home)) throw serverError("E_HOME_MISMATCH", `--home ${explicitHome} is not the saved route of ${name} on ${serverId} (${snap.home}); retire the saved one, or retire the other instance on the host by its own name`);
434
+ const remoteRetiresByHome = Array.isArray(remote?.features) && remote.features.includes("retire-home");
435
+ const wantHome = explicitHome || snap?.home;
436
+ if (wantHome && !remoteRetiresByHome) {
437
+ // An older kernel ignores --home and retires by name, first match. It
438
+ // is safe only when the name is unique there and is the saved home;
439
+ // an explicit --home is never sent where it cannot be honoured.
440
+ if (explicitHome) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} cannot retire by home (0.22.3 or later does); upgrade it there, or retire ${name} without --home once it is the only instance of that name`);
441
+ const st = runRemote(target, ["status", "--json", "--dir", target.workspace], io).envelope;
442
+ if (!st.ok) throw serverError(st.error?.code || "E_REMOTE", `cannot check ${name} on ${serverId} before retiring by name: ${st.error?.message || "status failed"}`);
443
+ const twins = (st.result?.agents || []).flatMap((a) => (a.instances || []).filter((i) => i.instance === name).map((i) => ({ agent: a.name, home: i.home })));
444
+ if (twins.length > 1) throw serverError("E_REMOTE_INCOMPATIBLE", `${name} names ${twins.length} instances on ${serverId} (${twins.map((t) => t.agent).join(", ")}) and remote oats ${remote.version} retires by name only; upgrade it to 0.22.3 or later so the saved home is retired and not a twin`);
445
+ if (twins.length === 1 && twins[0].home && resolve(twins[0].home) !== resolve(wantHome)) throw serverError("E_HOME_MISMATCH", `the only ${name} on ${serverId} lives at ${twins[0].home}, not at the saved route ${wantHome}; the saved route is stale (oats server roster --json)`);
446
+ }
447
+ const homeArgs = remoteRetiresByHome && wantHome && !explicitHome ? ["--home", wantHome] : [];
448
+ const { envelope, stderr } = runRemote(target, json(withScope(["retire", ...oatsArgs, ...homeArgs])), io);
377
449
  // The snapshot goes only when the remote home is gone: not on incomplete
378
450
  // cleanup, and not on a deferred completion still on its way there.
379
451
  if (envelope.ok && name && envelope.result?.removedDir !== false && !envelope.result?.rollbackIncomplete && !envelope.result?.deferred) removeSnapshot(serverId, name);
@@ -381,11 +453,120 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
381
453
  // not: an incomplete cleanup is exactly when the operator needs the host.
382
454
  return { envelope: envelope.result ? { ...envelope, result: { ...envelope.result, server: serverId, target } } : envelope, stderr };
383
455
  }
456
+ if (cmd === "harvest") {
457
+ // `oats okf harvest --json` reads its instance from cwd: run it in the
458
+ // SAVED home of an instance spawned through this route (no path from
459
+ // the caller), relaying the package's own envelope.
460
+ const name = instanceArg;
461
+ if (!snap?.home) throw serverError("E_SNAPSHOT_UNKNOWN", `no remote instance ${JSON.stringify(name || "")} spawned through server ${serverId} from this machine (oats server roster --json)`);
462
+ // A mutation on the host (it spawns a harvester there): version and
463
+ // envelope first. The remote list is a kernel-version proxy (0.22.3
464
+ // introduced this route and the envelope boundary it relies on); an
465
+ // older okf package there would still answer outside the envelope, which
466
+ // then fails as E_REMOTE_ENVELOPE rather than as a bad harvest.
467
+ const remote = checkRemote(target, io);
468
+ if (!Array.isArray(remote.remote) || !remote.remote.includes("harvest")) throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${target.sshHost} does not route okf harvest (0.22.3 or later does)`);
469
+ const { envelope, stderr } = runRemote(target, ["okf", "harvest", "--json"], { ...io, cwd: snap.home });
470
+ // The package's result names the HARVESTER it spawned (instance, window);
471
+ // the routing context goes under its own keys.
472
+ return { envelope: envelope.result ? { ...envelope, result: { ...envelope.result, server: serverId, sourceInstance: name, sourceHome: snap.home } } : envelope, stderr };
473
+ }
384
474
  if (cmd === "status") {
385
475
  const { envelope, stderr } = runRemote(target, json(withScope(["status", ...oatsArgs])), io);
386
476
  return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, target, snapshots: listSnapshots(serverId) } } : envelope, stderr };
387
477
  }
388
- throw serverError("E_USAGE", `--server routes spawn, retire, status and session attach only (not ${cmd})`);
478
+ throw serverError("E_USAGE", `--server routes spawn, retire, status, session and okf harvest only (not ${cmd})`);
479
+ }
480
+
481
+ // ------------------------------------------------------------------- roster
482
+
483
+ // The roster answers within a budget the Desktop can wait for (its adapter
484
+ // allows 60 s): each target gets the smaller of its own allowance and what is
485
+ // left, and targets the budget cannot reach are reported as such, never
486
+ // dropped and never allowed to sink the healthy groups.
487
+ export const ROSTER_BUDGET_MS = 45000;
488
+ export const ROSTER_PER_TARGET_TIMEOUT_MS = 20000;
489
+
490
+ function listSnapshotServers() {
491
+ const dir = REMOTE_SNAPSHOT_DIR();
492
+ if (!existsSync(dir)) return [];
493
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() && ID_RE.test(e.name)).map((e) => e.name);
494
+ }
495
+
496
+ /** The remote roster, grouped by server id and saved route target: one
497
+ * bounded status pull per group, registrations unioned with saved routes so
498
+ * a removed or changed registration keeps its group (registrationPresent
499
+ * false) and its instances from the snapshots (running null when the probe
500
+ * fails). Remote state is pulled, never cached; the saved route stays the
501
+ * action authority. */
502
+ export function rosterGroups({ server, io = {} } = {}) {
503
+ const started = Date.now();
504
+ if (server && !ID_RE.test(server)) throw serverError("E_BAD_ARGS", `server id ${JSON.stringify(server)} is not a valid id`);
505
+ if (server && !readServers()[server] && !existsSync(join(REMOTE_SNAPSHOT_DIR(), server))) throw serverError("E_SERVER_UNKNOWN", `no server registered as ${JSON.stringify(server)} and no saved routes for it (oats server list)`);
506
+ const perTargetTimeoutMs = io.perTargetTimeoutMs || ROSTER_PER_TARGET_TIMEOUT_MS;
507
+ const budgetMs = io.budgetMs || ROSTER_BUDGET_MS;
508
+ let skipped = 0;
509
+ const servers = readServers();
510
+ const groups = new Map();
511
+ const add = (serverId, target, registrationPresent, label) => {
512
+ const key = `${serverId}:${targetKey(target)}`;
513
+ if (!groups.has(key)) groups.set(key, { id: key, server: serverId, label: label || serverId, registrationPresent, target, probe: null, agentsRoot: undefined, souls: [], instances: [], retireFailures: [], _snapshots: [] });
514
+ const g = groups.get(key);
515
+ if (registrationPresent) g.registrationPresent = true;
516
+ return g;
517
+ };
518
+ for (const [id, s] of Object.entries(servers)) { if (server && id !== server) continue; add(id, targetOf({ id, ...s }), true, s.label); }
519
+ for (const serverId of listSnapshotServers()) {
520
+ if (server && serverId !== server) continue;
521
+ for (const snap of listSnapshots(serverId)) { if (!snap.target) continue; add(serverId, snap.target, false, servers[serverId]?.label)._snapshots.push(snap); }
522
+ }
523
+ for (const g of groups.values()) {
524
+ let status;
525
+ const remaining = budgetMs - (Date.now() - started);
526
+ if (remaining < 1000) {
527
+ skipped++;
528
+ g.probe = { ok: false, error: { code: "E_ROSTER_BUDGET", message: `not probed: the ${budgetMs} ms roster budget was used up by earlier targets` } };
529
+ } else {
530
+ try { status = runRemote(g.target, ["status", "--json", "--dir", g.target.workspace], { ...io, timeoutMs: Math.min(perTargetTimeoutMs, remaining) }).envelope; }
531
+ catch (e) { g.probe = { ok: false, error: { code: e.code || "E_SSH", message: e.message } }; }
532
+ }
533
+ if (status && !status.ok) g.probe = { ok: false, error: status.error || { code: "E_REMOTE", message: "status failed" } };
534
+ // A saved route matches a remote row by name AND home: a same-named
535
+ // twin under another soul on the host is observed only, never given the
536
+ // route (the route's own row is appended as stale if the host no longer
537
+ // lists that home).
538
+ const bySnapshot = new Map(g._snapshots.map((s) => [s.instance, s]));
539
+ const routeOf = (i) => { const s = bySnapshot.get(i.instance); return s && s.home && i.home && resolve(s.home) === resolve(i.home) ? s : undefined; };
540
+ if (status?.ok) {
541
+ g.probe = { ok: true };
542
+ g.agentsRoot = status.result.root;
543
+ for (const a of status.result.agents || []) {
544
+ g.souls.push({ name: a.name, runtime: a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
545
+ // A failed deferred self-retirement needs an operator: it rides with
546
+ // the group, named by agent and instance, as `oats status` prints it.
547
+ for (const f of a.retireFailures || []) g.retireFailures.push({ agent: a.name, ...f });
548
+ for (const i of a.instances || []) {
549
+ const snap = routeOf(i);
550
+ g.instances.push({
551
+ server: g.server, instance: i.instance, agent: a.name, home: i.home || snap?.home, agentsRoot: status.result.root,
552
+ runtime: i.runtime || null, backend: i.sessionTarget?.backend || (i.tmux ? "tmux" : null),
553
+ ...(i.sessionTarget ? { sessionTarget: i.sessionTarget } : {}), ...(i.tmux ? { tmux: i.tmux } : {}),
554
+ running: typeof i.running === "boolean" ? i.running : null, ...(i.runtimeError ? { runtimeError: i.runtimeError } : {}),
555
+ retirePending: !!i.retirePending, rollbackIncomplete: !!i.rollbackIncomplete, savedRoute: !!snap, missingRemotely: false,
556
+ });
557
+ if (snap) bySnapshot.delete(i.instance);
558
+ }
559
+ }
560
+ }
561
+ // Saved routes the remote did not list: with a good probe that means the
562
+ // instance is gone there (retired, or its home removed) and the route is
563
+ // stale; with a failed probe nothing is known. Same keys as remote rows.
564
+ for (const snap of bySnapshot.values()) {
565
+ g.instances.push({ server: g.server, instance: snap.instance, agent: snap.agent, home: snap.home, agentsRoot: snap.agentsRoot, runtime: snap.runtime || null, backend: null, running: null, retirePending: false, rollbackIncomplete: false, savedRoute: true, missingRemotely: g.probe?.ok === true });
566
+ }
567
+ delete g._snapshots;
568
+ }
569
+ return { groups: [...groups.values()], bounds: { budgetMs, perTargetTimeoutMs, elapsedMs: Date.now() - started, skipped } };
389
570
  }
390
571
 
391
572
  /** argv for an INTERACTIVE remote command (a viewer attach): ssh with a PTY,
@@ -397,8 +578,14 @@ export function routeCommand(serverId, cmd, oatsArgs, io = {}) {
397
578
  * target with a caller-supplied absolute remote home. Nothing about the
398
579
  * remote binary or path ever comes from the caller. */
399
580
  export function resolveRoute(serverId, { instance, home } = {}, what = "session") {
400
- const snap = instance ? readSnapshot(serverId, instance) : undefined;
581
+ // The home is the identity (same-named twins on one host are different
582
+ // instances); a name alone resolves through its saved route. With a home,
583
+ // the saved route that owns it supplies the target (a registration edited
584
+ // later never re-routes a viewer); a name AND a home must agree.
585
+ let snap = instance ? readSnapshot(serverId, instance) : undefined;
401
586
  if (instance && !snap) throw serverError("E_SNAPSHOT_UNKNOWN", `no remote instance ${JSON.stringify(instance)} spawned through server ${serverId} from this machine (oats status --server ${serverId})`);
587
+ if (home && snap?.home && resolve(snap.home) !== resolve(home)) throw serverError("E_HOME_MISMATCH", `--home ${home} is not the saved route of ${instance} on ${serverId} (${snap.home})`);
588
+ if (home && !snap) snap = listSnapshots(serverId).find((s) => s.home && resolve(s.home) === resolve(home));
402
589
  const target = snap?.target || targetOf(getServer(serverId));
403
590
  const remoteHome = home || snap?.home;
404
591
  if (!remoteHome || !String(remoteHome).startsWith("/")) throw serverError("E_BAD_ARGS", `${what} --server needs --instance <name> (spawned from here) or --home </absolute/remote/home>`);
@@ -6,7 +6,9 @@ import { inspectHerdr, inputHerdr, herdrCommand } from "./herdr.mjs";
6
6
 
7
7
  const shells = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "login"]);
8
8
  function tmux(target, args, { exec = execFileSync, input } = {}) {
9
- return exec("tmux", ["-S", target.socket, ...args], {
9
+ // launchd does not supply a UTF-8 locale. In that environment tmux replaces
10
+ // the tab delimiters in list-panes output with underscores unless forced.
11
+ return exec("tmux", ["-u", "-S", target.socket, ...args], {
10
12
  encoding: "utf8", input, timeout: 10000, maxBuffer: 1024 * 1024,
11
13
  stdio: ["pipe", "pipe", "pipe"],
12
14
  });
@@ -10,7 +10,9 @@ export function prepareSessionViewer(target, { exec = execFileSync } = {}) {
10
10
  delete env.HERDR_SESSION;
11
11
  return { binary: target.binary, args: ["terminal", "attach", target.terminalId], env, cleanup() {} };
12
12
  }
13
- const run = (args) => exec("tmux", ["-S", target.socket, ...args], {
13
+ // UTF-8 mode (-u) like the shared session helper: a service without a
14
+ // UTF-8 locale (launchd) otherwise mangles tmux output.
15
+ const run = (args) => exec("tmux", ["-u", "-S", target.socket, ...args], {
14
16
  encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
15
17
  }).trim();
16
18
  const viewer = `oatsview-${process.pid}-${randomUUID().slice(0, 8)}`;
@@ -28,7 +30,7 @@ export function prepareSessionViewer(target, { exec = execFileSync } = {}) {
28
30
  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
31
  run(["set-option", "-t", viewer, "mouse", "on"]);
30
32
  const env = { ...process.env }; delete env.TMUX;
31
- return { binary: "tmux", args: ["-S", target.socket, "attach-session", "-t", `=${viewer}`], env, cleanup };
33
+ return { binary: "tmux", args: ["-u", "-S", target.socket, "attach-session", "-t", `=${viewer}`], env, cleanup };
32
34
  } catch (e) { cleanup(); throw e; }
33
35
  }
34
36
 
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "oats.aweb": {
9
9
  "url": "https://github.com/awebai/oats-aweb.git",
10
- "ref": "v1.9.0",
10
+ "ref": "v1.10.1",
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.2",
3
+ "version": "0.22.4",
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",
@@ -23,7 +23,7 @@ import { join } from "node:path";
23
23
  import process from "node:process";
24
24
 
25
25
  import { RecordStore } from "../lib/store.mjs";
26
- import { RecordIndex } from "../lib/index-db.mjs";
26
+ import { extractTurnText, RecordIndex } from "../lib/index-db.mjs";
27
27
 
28
28
  function parseArgs(argv) {
29
29
  const args = { _: [] };
@@ -40,16 +40,19 @@ function parseArgs(argv) {
40
40
  const args = parseArgs(process.argv.slice(2));
41
41
  const root = args.root ?? process.env.TURN_RECORD_ROOT ?? join(homedir(), ".turn-record");
42
42
  const store = new RecordStore(root, {});
43
- const index = new RecordIndex(store);
43
+ let index;
44
+ const getIndex = () => (index ??= new RecordIndex(store));
44
45
 
45
46
  try {
46
47
  if (args.reindex) {
48
+ const index = getIndex();
47
49
  index.rebuild();
48
50
  console.log(JSON.stringify(index.counts()));
49
51
  process.exit(0);
50
52
  }
51
53
 
52
54
  if (args.show) {
55
+ const index = getIndex();
53
56
  const { resolveTurn } = await import("../lib/index-db.mjs");
54
57
  const byId = store.readAll();
55
58
  const turn = resolveTurn(store, index, args.show, byId);
@@ -111,7 +114,7 @@ try {
111
114
  // A window is a bounded read: a consumer that plans one (the OKF
112
115
  // harvester) sizes it with --ids-only first, then reads exactly that.
113
116
  const out = window.map((t) => {
114
- const docs = index.extractText(t);
117
+ const docs = extractTurnText(t);
115
118
  const base = { id: t.id, ts: t.ts, thread: t.thread, kind: t.kind, source: t.provenance?.source ?? null };
116
119
  const full = { ...base, text: docs.map((d) => ({ role: d.role, text: d.text })) };
117
120
  // bytes = what this turn occupies in the pretty-printed --json answer,
@@ -123,6 +126,7 @@ try {
123
126
  process.exit(0);
124
127
  }
125
128
 
129
+ const index = getIndex();
126
130
  const limit = args.limit ? Number(args.limit) : 20;
127
131
  let rows;
128
132
  if (query) {
@@ -164,5 +168,5 @@ try {
164
168
  console.log(` ${r.id}`);
165
169
  }
166
170
  } finally {
167
- index.close();
171
+ index?.close();
168
172
  }
@@ -413,20 +413,7 @@ export class RecordIndex {
413
413
  // Text documents for one turn. Mail/chat: one doc. Session: one doc per
414
414
  // conversational event extracted from the transcript blob.
415
415
  extractText(turn) {
416
- if (turn.kind === "note" && turn.body?.segment) return []; // dedicated role "segment" row
417
- if (turn.kind === "mail" || turn.kind === "chat" || turn.kind === "note") {
418
- const subject = turn.body?.subject ?? "";
419
- const text = turn.body?.text ?? "";
420
- const joined = subject ? subject + "\n" + text : text;
421
- return joined.trim() ? [{ loc: "", role: turn.kind, text: joined }] : [];
422
- }
423
- if (turn.kind === "session" && typeof turn.body?.line === "string") {
424
- const loc = `line:${turn.provenance?.origin?.line ?? 0}`;
425
- return extractSessionTextFor(turn.provenance?.source, Buffer.from(turn.body.line, "utf8")).map(
426
- (d) => ({ ...d, loc }),
427
- );
428
- }
429
- return [];
416
+ return extractTurnText(turn);
430
417
  }
431
418
 
432
419
  // FTS query with optional filters. Returns rows with turn metadata and a
@@ -502,6 +489,24 @@ export class RecordIndex {
502
489
  }
503
490
  }
504
491
 
492
+ // Journal text extraction is independent of the derived search database.
493
+ export function extractTurnText(turn) {
494
+ if (turn.kind === "note" && turn.body?.segment) return []; // dedicated role "segment" row
495
+ if (turn.kind === "mail" || turn.kind === "chat" || turn.kind === "note") {
496
+ const subject = turn.body?.subject ?? "";
497
+ const text = turn.body?.text ?? "";
498
+ const joined = subject ? subject + "\n" + text : text;
499
+ return joined.trim() ? [{ loc: "", role: turn.kind, text: joined }] : [];
500
+ }
501
+ if (turn.kind === "session" && typeof turn.body?.line === "string") {
502
+ const loc = `line:${turn.provenance?.origin?.line ?? 0}`;
503
+ return extractSessionTextFor(turn.provenance?.source, Buffer.from(turn.body.line, "utf8")).map(
504
+ (d) => ({ ...d, loc }),
505
+ );
506
+ }
507
+ return [];
508
+ }
509
+
505
510
  // Claude Code text extraction, re-exported for compatibility; the
506
511
  // per-format extractors live in formats.mjs.
507
512
  export const extractSessionText = extractCcText;