@awebai/oats 0.39.4 → 0.40.1

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/docs/schedules.md CHANGED
@@ -38,7 +38,7 @@ The design is in the
38
38
  | `<deployment>/oats-schedules.json` | This machine's local definitions, `{version: 1, jobs: {<id>: …}}`, local triggers included (`kind: "trigger"`). |
39
39
  | `<deployment>/.agents/automations/snapshot.json` | The workspace definitions discovered from the members. |
40
40
  | `<deployment>/.agents/schedules/` | Run state: `state.json` (last minute and recent runs per job), `triggers.json` (polls, pending events, fired keys) and one lock directory per running job. |
41
- | `~/.oats/schedules/registry.json` | The deployments this host ticks, `maxConcurrent` (default 1: running scheduled jobs) and `triggersMaxConcurrent` (absent: no host cap on trigger-spawned live instances). The two caps are separate. |
41
+ | `~/.oats/schedules/registry.json` | The deployments this host ticks, `maxConcurrent` (absent: default 5 running scheduled jobs) and `triggersMaxConcurrent` (absent: no host cap on trigger-spawned live instances). The two caps are separate. |
42
42
 
43
43
  One host lock serializes ticks, run-now, reconcile and remove. It is never
44
44
  reclaimed by another process: a lock whose owner is gone is reported with the
@@ -48,7 +48,18 @@ directory to remove.
48
48
 
49
49
  Every definition carries `id`, `enabled`, `cron`, `tz` and `kind`. `cron` has
50
50
  five fields (minute hour day month weekday) and `tz` is a required IANA zone;
51
- both are evaluated by the croner library.
51
+ both are evaluated by the croner library. Schedule IDs use lowercase letters,
52
+ digits and dashes, from 1 to 100 characters. Spawn schedules with a long ID
53
+ need an explicit shorter `purpose` to fit the instance-name limit below.
54
+
55
+ Any kind may carry `description`: what the job is for, in words, for the
56
+ people reading `oats schedule list`, `show` and the Desktop. It is one line of
57
+ 1 to 200 characters with no control characters (no CR, LF, TAB or any other
58
+ C0 or C1 character, nor a Unicode line or paragraph separator); anything else
59
+ is `E_SCHEDULE_INVALID` with `field: "description"`. It is stored as given and
60
+ is informational only: it never reaches a run's argv, environment, task or
61
+ reconcile. A capability that registers jobs (knowledge harvest's `run-source`
62
+ jobs) sets it so that its command jobs can be told apart.
52
63
 
53
64
  - **spawn** `{…, agent, agentsRoot?, repo?, backend?, purpose?, task,
54
65
  launchConfig?, harness?, model?, yolo?, wake?}` — every due minute launches
@@ -70,7 +81,13 @@ both are evaluated by the croner library.
70
81
  no shell; `oats schedule` itself is refused) in `cwd`, an existing directory
71
82
  inside the deployment. The runner tracks any instance the command's
72
83
  envelope names, including an independent worker it reports, until its home
73
- is gone. A command's return is not task completion.
84
+ is gone. A command's return is not task completion. Command/operation runs and
85
+ workspace-spawn launches have a five-minute child timeout. The supervisor
86
+ sends SIGTERM to the child's process group, allows two seconds for cleanup,
87
+ then sends SIGKILL if the group remains. It observes the direct child's exit
88
+ before returning; inherited output pipes cannot hold the tick indefinitely.
89
+ A timeout leaves effects unconfirmed even if the child printed an envelope.
90
+ Spawn previews use the same bounded runner.
74
91
  - **wake** `{…, home, message}` — every due minute inspects the instance at
75
92
  `home`. Running: `message` is delivered once as terminal input (bracketed
76
93
  paste plus Enter), never an interrupt. Not running: the home is started with
@@ -220,7 +237,8 @@ owner: github.com/ana
220
237
  is an `E_AUTOMATION_SCHEMA` problem, never silently skipped.
221
238
  - **The id** is `id:`, else the filename stem. The same id twice in one member
222
239
  for one kind is `E_AUTOMATION_DUPLICATE`, naming both paths; the second file
223
- is not listed. A trigger and a schedule may share an id. A member named
240
+ is not listed. Schedule IDs allow 1 to 100 lowercase letters, digits and dashes; trigger
241
+ IDs allow 1 to 40. A trigger and a schedule may share an id. A member named
224
242
  `local` is refused, because `local/<id>` names this host's own definitions.
225
243
  - **A workspace schedule is `run: spawn` or `run: command`.** A command's
226
244
  `cwd` is relative to the deployment and must stay inside it. `wake` and
@@ -288,6 +306,8 @@ oats schedule test <id> # dry run: where it runs, whether its soul res
288
306
  oats schedule tick [--dry-run] # evaluate this deployment now; --dry-run launches nothing
289
307
  oats schedule reconcile <id> [--clear] # resolve an attempt whose result was never recorded
290
308
  oats schedule host install # register this deployment and install the one host timer (idempotent)
309
+ oats schedule host install --max-concurrent 3 --triggers-max-concurrent 2
310
+ oats schedule host install --max-concurrent default --triggers-max-concurrent none
291
311
  oats schedule host status | uninstall
292
312
  oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --wake-file spec.json
293
313
  ```
@@ -295,7 +315,26 @@ oats spawn <agent> ... --wake-every 15 --wake-message "Anything new?" # or --w
295
315
  `<id>` is `local/<id>` (or the bare id) or `<member>/<id>`. `host uninstall`
296
316
  unregisters the deployment and removes the timer once none is registered.
297
317
  Every `oats schedule` subcommand takes `--server <id>` instead of `--dir` to
298
- run on that registered server.
318
+ run on that registered server. Setting or resetting host caps remotely requires
319
+ the destination to advertise both `schedule` and `schedule-host-caps`; an older
320
+ or unknown peer is refused with `E_REMOTE_INCOMPATIBLE` before host install is
321
+ forwarded. Upgrade OATS on the destination to use these options. A remote
322
+ install without cap options retains its existing behavior.
323
+
324
+ The host allows five running scheduled jobs by default. Use `host install
325
+ --max-concurrent N` to choose a positive integer, or `--max-concurrent default`
326
+ to restore the default. `--triggers-max-concurrent N` independently limits live
327
+ trigger-spawned instances; `none` removes that cap. Omitted flags preserve the
328
+ current choices. Invalid values fail with `E_BAD_ARGS` before registration or
329
+ timer changes. `host status` reports the effective `maxConcurrent` and
330
+ `triggersMaxConcurrent` (`null` when uncapped).
331
+
332
+ The registry stores explicit choices only. On the first registry read after
333
+ upgrade, a legacy stored `maxConcurrent: 1` without the new choice marker is
334
+ migrated to the default under the registry lock; other explicit values survive.
335
+ If you need a cap of one, run `oats schedule host install --max-concurrent 1`
336
+ after upgrading. That explicit choice survives later reads and reinstalls.
337
+ Set these values through the CLI; do not edit the registry by hand.
299
338
 
300
339
  `oats schedule list --json` answers:
301
340
 
@@ -306,14 +345,14 @@ run on that registered server.
306
345
  schedules: [ <row> ],
307
346
  triggers: { count, command: "oats trigger list" },
308
347
  snapshot: { takenAt, problems } | null,
309
- scheduler: { installed, active, unit?, lastTick, maxConcurrent, tickIntervalSec,
348
+ scheduler: { installed, active, unit?, lastTick, maxConcurrent, triggersMaxConcurrent, tickIntervalSec,
310
349
  workspace, registered, workspaces, live } }
311
350
  ```
312
351
 
313
352
  Each row is the stored definition plus `id` (bare for a local schedule,
314
- `<member>/<id>` for a workspace one), `qualifiedId`, `origin`, `owner`,
315
- `runsOn`, `runsHere`, `reason`, `enabledHere`, `soul`, `nextDue`, `lastRun`,
316
- `recentRuns` and `running`; an unreadable row carries `unreadable: { code,
353
+ `<member>/<id>` for a workspace one), `qualifiedId`, `description` (`null`
354
+ when there is none), `origin`, `owner`, `runsOn`, `runsHere`, `reason`,
355
+ `enabledHere`, `soul`, `nextDue`, `lastRun`, `recentRuns` and `running`; an unreadable row carries `unreadable: { code,
317
356
  message }` instead of failing the list. `scheduler.active` is what the OS
318
357
  reports about the timer. `oats trigger list --json` carries the same
319
358
  `scheduler`. The field-level contract is in
@@ -328,12 +367,42 @@ you), `launch-failed`, `unknown`, and for wake jobs `delivered`, `started` or
328
367
  `skipped`. The kernel never claims a task succeeded.
329
368
 
330
369
  `unknown` means the launch's side effects are unconfirmed: a command timed out
331
- or answered no envelope, or an attempt was never recorded. The job keeps its
332
- slot and is skipped until `oats schedule reconcile <id>`, which adopts only an
333
- attributable receipt (a spawn job's instance, named for its minute, or the
334
- instance a command's answer named). When nothing is attributable, check the
335
- roster and the host by hand, then `reconcile <id> --clear` records
336
- `launch-failed` and frees the slot.
370
+ or answered no envelope, or an attempt was never recorded. The job is skipped
371
+ until `oats schedule reconcile <id>`, which adopts only an attributable
372
+ receipt (a spawn job's instance, named for its minute, or the instance a
373
+ command's answer named). When nothing is attributable, check the roster and
374
+ the host by hand, then `reconcile <id> --clear` records `launch-failed` and
375
+ frees the slot. The unresolved attempt shows in `show` as `attempt:
376
+ {scheduledFor, startedAt, error?, exited?, exitStatus?, exitSignal?}`;
377
+ `error` is the first run's cause, which later skipped ticks keep in `lastRun`.
378
+
379
+ `oats doctor` warns about each unresolved attempt, in this deployment and in
380
+ the other deployments this host ticks (they share its slots). The text form
381
+ is `! schedule-unresolved: …`; in `--json` it is a `problems[]` item:
382
+
383
+ ```text
384
+ { code: "schedule-unresolved", severity: "warning", scope, id, kind,
385
+ scheduledFor, startedAt, ageSeconds, holdsSlot, exited, error, remedy, message }
386
+ ```
387
+
388
+ `holdsSlot` says whether the job counts against `maxConcurrent`. `remedy` is
389
+ `oats schedule reconcile <id>`, with `--clear` for a command or operation
390
+ whose effects no named home proves (check the roster and the host by hand
391
+ first), and with `--dir <scope>` for another deployment. A warning never
392
+ changes doctor's exit status. Workspace command kinds come from the last
393
+ saved automations snapshot, without a refresh or an account lookup. Unresolved
394
+ workspace state is reported even if its definition is no longer available.
395
+
396
+ Whether an `unknown` job keeps its host slot depends on what is still running:
397
+
398
+ - A `command` or `operation` job whose process exit the kernel observed (it
399
+ returned, or was stopped at the five-minute timeout) holds no slot: the
400
+ process runs nothing any more, and an instance it spawned has its own
401
+ lifecycle. Its attempt shows `exited: true` with the exit status or
402
+ signal, and other jobs keep running.
403
+ - A `spawn` job keeps its slot, which stands for the instance it may have
404
+ launched. So does a command whose exit was not observed (the runner threw,
405
+ the process never started) and a legacy attempt without exit evidence (no `exited`), until reconcile.
337
406
 
338
407
  **Slots.** A wake job that starts a stopped home holds a launch slot until the
339
408
  harness is proven stopped or the home is gone; delivering to a running home
@@ -344,7 +413,7 @@ continues.
344
413
 
345
414
  **Changing a job.** `disable` never stops anything. `update` never touches a
346
415
  running instance, and while a job holds a slot or has an unresolved attempt
347
- only `cron`, `tz` and `enabled` can change. `remove` refuses while the job's
416
+ only `cron`, `tz`, `enabled` and `description` can change. `remove` refuses while the job's
348
417
  instance is tracked or its effects are unresolved (`--force` forgets the job
349
418
  without stopping anything). Retiring an instance removes the wake jobs bound
350
419
  to its home.
@@ -54,7 +54,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
54
54
  - git:github.com/acme/tools # a member that ALSO publishes a package (see below)
55
55
 
56
56
  packages: # the ONLY versioned things
57
- oats.framework: v1.5.0 # bare version → resolves through the official catalog
57
+ oats.framework: v1.6.0 # bare version → resolves through the official catalog
58
58
  oats.okf: v4.1.1
59
59
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
60
60
 
@@ -45,6 +45,8 @@ export const PLACEMENT_REASONS = Object.freeze(["host-unnamed", "assigned-elsewh
45
45
  export const KIND_NAMES = Object.freeze(["trigger", "schedule"]);
46
46
  const NEVER_SCANNED = new Set(["oats-package", ".git", "node_modules"]);
47
47
  export const AUTOMATION_ID_RE = /^[a-z0-9-]{1,40}$/;
48
+ /** Schedule definition names have a wider bound than trigger names. */
49
+ export const SCHEDULE_NAME_RE = /^[a-z0-9-]{1,100}$/;
48
50
  /** A model id an automation may pass to `oats spawn --model`: a provider/model id, never an option.
49
51
  * The first character is alphanumeric (or the spawn CLI's own `@native-default`), so no value can
50
52
  * be read as a flag (re-review B #1). `[` `]` admit a harness's context-size alias (`opus[1m]`). */
@@ -106,7 +108,8 @@ export function parseAutomationFile(desc, { stem, path, bytes, member, repoKey,
106
108
  const unknown = Object.keys(doc).filter((k) => !allowed.includes(k));
107
109
  if (unknown.length) return problem(`unknown field${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} (a ${desc.fileKind} carries ${allowed.join(", ")})`, unknown[0]);
108
110
  const name = doc.id === undefined ? stem : doc.id;
109
- if (typeof name !== "string" || !AUTOMATION_ID_RE.test(name)) return problem(`the id (${doc.id === undefined ? "the filename stem" : "id:"} ${JSON.stringify(name)}) must be lowercase letters, digits and dashes, 1 to 40 characters`, "id");
111
+ const namePattern = desc.kind === "schedule" ? SCHEDULE_NAME_RE : AUTOMATION_ID_RE;
112
+ if (typeof name !== "string" || !namePattern.test(name)) return problem(`the id (${doc.id === undefined ? "the filename stem" : "id:"} ${JSON.stringify(name)}) must be lowercase letters, digits and dashes, 1 to ${desc.kind === "schedule" ? 100 : 40} characters`, "id");
110
113
  if (typeof doc.runsOn !== "string" || !HOST_NAME_RE.test(doc.runsOn)) return problem("runsOn: the host name that runs it (oats-local.yaml host.name: lowercase letters, digits and dashes)", "runsOn");
111
114
  const owner = parseOwner(doc.owner);
112
115
  if (!owner) return problem("owner: the GitHub account it acts as, <host>/<login> (e.g. github.com/acme-kb-bot)", "owner");
package/lib/core.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
33
33
  } from "node:fs";
34
34
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
35
+ import { homedir } from "node:os";
35
36
  import { accessSync, constants as fsConstants } from "node:fs";
36
37
  import { recordLocalInput } from "./local-inputs.mjs";
37
38
  import { createHash, randomUUID } from "node:crypto";
@@ -40,7 +41,7 @@ import { initializeNativeHistory, prepareNativeStart } from "../packages/record/
40
41
  import { noteRuntimeName } from "./deprecation.mjs";
41
42
  import { attachSessionTarget } from "./session-viewer.mjs";
42
43
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
43
- import { appendEvent } from "./instance-events.mjs";
44
+ import { appendEvent, liveWaiting, recordStartBoundary } from "./instance-events.mjs";
44
45
  import { killGroup } from "./process-group.mjs";
45
46
 
46
47
  import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
@@ -204,7 +205,7 @@ function shIn(cwd, cmdline, timeout = 45000) {
204
205
  return execSync(cmdline, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
205
206
  }
206
207
  function shInTry(cwd, cmdline, timeout) { try { return shIn(cwd, cmdline, timeout); } catch { return undefined; } }
207
- function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
208
+ export function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
208
209
  export function slug(s) {
209
210
  const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
210
211
  return r || "agent";
@@ -2008,7 +2009,7 @@ export const RELATIONS = ["child", "sibling", "parent", "unrelated"];
2008
2009
  /** Verify the harness packages that ACTIVE capabilities require for `harness`.
2009
2010
  *
2010
2011
  * Each comes from a declared harness-package requirement, so the capability has
2011
- * stated the dependency and the user has consented to install it (`oats install`).
2012
+ * stated the dependency. Installing it remains the operator's responsibility.
2012
2013
  * We verify PRESENCE and record provenance; we deliberately do NOT resolve the
2013
2014
  * extension's entry file. pi owns that resolution — its manifest supports globs
2014
2015
  * and exclusions, packages without a `pi` manifest use conventional directories,
@@ -2050,8 +2051,16 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
2050
2051
  const status = harnessPackageStatus(harness, spec, probeEnv, probeOpts);
2051
2052
  const mgr = HARNESS_PACKAGE_MANAGERS[harness];
2052
2053
  const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
2053
- const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
2054
- const remedy = `run \`oats install --accept-requirement ${harness}:${harnessPackageIdentity(harness, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
2054
+ // Keep the remedy in the same resource directory and selected wrapper as
2055
+ // the probe; quote argv, never turn a path or package selector into shell code.
2056
+ // Pi expands ~ against the launch HOME; relative selectors are anchored
2057
+ // to the probe cwd before the remedy changes to contextDir.
2058
+ const piResourceDir = harness === "pi" ? piAgentDir(probeEnv).replace(/^~(?=$|\/)/, () => probeEnv.HOME || homedir()) : undefined;
2059
+ const resourceEnv = harness === "pi" ? `PI_CODING_AGENT_DIR=${shq(resolve(piResourceDir))} `
2060
+ : harness === "claude" && probeEnv.CLAUDE_CONFIG_DIR ? `CLAUDE_CONFIG_DIR=${shq(resolve(probeEnv.CLAUDE_CONFIG_DIR))} ` : "";
2061
+ const direct = stepList.filter((a) => a.length).map((a) => resourceEnv + [bin || a[0], ...a.slice(1)].map(shq).join(" ")).join(" && ");
2062
+ const remedy = direct ? `ask the operator to run \`cd ${shq(contextDir)} && ${direct}\` with the selected harness's environment; OATS does not install packages during spawn or restart`
2063
+ : `ask the operator to install it with the selected harness's package manager`;
2055
2064
  // `ifInstalled: true`: the row constrains a package that may be absent
2056
2065
  // (an ambient extension must honour a contract IF it is there); absence
2057
2066
  // satisfies it. Without the flag, absence fails as before.
@@ -3948,7 +3957,18 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3948
3957
  ...(meta.home !== undefined && meta.home !== home ? { recordedHome: meta.home } : {}),
3949
3958
  ...(meta.instance !== undefined && meta.instance !== e.name ? { recordedInstance: meta.instance } : {}),
3950
3959
  };
3951
- return { ...meta, ...claims, home, instance: e.name, ...facts, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
3960
+ // Feature waiting-on-you: a producer's live claim that the instance is
3961
+ // blocked on a human, read only for a running row (one bounded read of
3962
+ // its home log); null otherwise, and null means unknown. `running` is
3963
+ // the window's presence, which a crashed harness's fallback shell or a
3964
+ // retained dead pane keeps: a row with a claim is shown only when its
3965
+ // session is observed running a harness, as session inspect reports it.
3966
+ let waitingOnYou = liveness.running === true ? liveWaiting(home) : null;
3967
+ if (waitingOnYou) {
3968
+ try { const s = instanceSessionTarget(home); if (!s.target || !harnessRunning(inspectSessionTarget(s.target))) waitingOnYou = null; }
3969
+ catch { waitingOnYou = null; }
3970
+ }
3971
+ return { ...meta, ...claims, home, instance: e.name, ...facts, ...(identity ? { identity } : {}), ...(meta.launch && typeof meta.launch === "object" ? { launch: redactLaunchRecipe(meta.launch) } : {}), ...(typeof meta.command === "string" ? { command: redactLaunchCommand(meta.command) } : {}), ...liveness, waitingOnYou, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
3952
3972
 
3953
3973
  });
3954
3974
  };
@@ -4324,6 +4344,12 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
4324
4344
  * otherwise every stop or event write would read as "changed home bytes". */
4325
4345
  const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
4326
4346
  const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
4347
+ /** Harness project settings in the home are configuration, not work: the
4348
+ * home's `.claude/` is harness layout the kernel already shapes (the skills
4349
+ * alias), and capabilities keep their own entries current in its
4350
+ * settings.json at every launch. A retirement fingerprint ignores exactly
4351
+ * that path, home-relative. */
4352
+ const HARNESS_HOME_SETTINGS = new Set([join(".claude", "settings.json")]);
4327
4353
  function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
4328
4354
  const hash = createHash("sha256");
4329
4355
  const rootStat = lstatSync(root);
@@ -4338,6 +4364,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
4338
4364
  for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
4339
4365
  if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
4340
4366
  const childRel = rel ? join(rel, e.name) : e.name;
4367
+ if (instanceHome && HARNESS_HOME_SETTINGS.has(childRel)) continue;
4341
4368
  const path = join(dir, e.name);
4342
4369
  const st = lstatSync(path);
4343
4370
  hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
@@ -4565,12 +4592,18 @@ function instanceSessionTarget(home) {
4565
4592
  }
4566
4593
 
4567
4594
  export function inspectInstanceSession(home) {
4568
- if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
4595
+ if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped", waitingOnYou: null };
4569
4596
  const s = instanceSessionTarget(home);
4570
- if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
4571
- try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
4597
+ if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched", waitingOnYou: null };
4598
+ let observed;
4599
+ try { observed = inspectSessionTarget(s.target); }
4572
4600
  catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
4601
+ // Feature waiting-on-you: beside `state`, whose enum is unchanged.
4602
+ return { home: s.home, ...observed, waitingOnYou: harnessRunning(observed) ? liveWaiting(s.home) : null };
4573
4603
  }
4604
+ /** Whether an observed session runs a harness: present, and neither a
4605
+ * fallback shell nor a dead pane. Only such a session can be waiting on a human. */
4606
+ function harnessRunning(observed) { return observed?.present === true && observed.state !== "shell" && observed.state !== "stopped"; }
4574
4607
 
4575
4608
  /** K3: quiesce one instance's session and RETAIN everything else — home,
4576
4609
  * worktree, transcript, launch configuration — so `restart` can bring it back.
@@ -5125,6 +5158,9 @@ export function startInstanceSession(home, o = {}) {
5125
5158
  // Reconcile even an exited target: the independent baseline may
5126
5159
  // already name it while metadata still names the old allocation.
5127
5160
  const meta = readMeta();
5161
+ // The adopted start's session boundary, at its launch time, completed
5162
+ // in whichever log the interrupted start did not record it.
5163
+ recordStartBoundary(realHome, { startId: pending.id, startedAt: pending.startedAt, harness: pending.harness ?? meta.harness ?? null, backend: "tmux", launchConfig: pending.launch?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: "recovered" });
5128
5164
  const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
5129
5165
  if (st.present && st.state !== "shell") {
5130
5166
  if (o.restart) { rmSync(pendingPath, { force: true }); }
@@ -5305,6 +5341,10 @@ export function startInstanceSession(home, o = {}) {
5305
5341
  catch (e) { throw launchFailure(e); }
5306
5342
  }
5307
5343
  target = { backend: "tmux", session, window, socket: resolve(socket) };
5344
+ // The session exists: its boundary (lib/instance-events.mjs) is recorded
5345
+ // now, before the metadata, so a start whose metadata write fails still
5346
+ // voids the claims of the session it replaced; its adoption only completes a log it missed.
5347
+ recordStartBoundary(realHome, { startId: id, startedAt, harness: launchPlan?.harness || harness, backend: "tmux", launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: o.restart ? "restart" : "start" });
5308
5348
  // Keep launch evidence until the command exits or the target disappears.
5309
5349
  // A transient child (for example the native-start recorder) is not proof that startup
5310
5350
  // has finished. A later start reconciles the receipt without a watcher.
@@ -18,7 +18,15 @@
18
18
  * recreated same-address instance's earlier rows are visible as earlier;
19
19
  * - `waitingOnYou` is a producer STATE per (producer, current incarnation):
20
20
  * that producer's latest row carrying the field decides, an explicit `false`
21
- * clears, and it is computed over the full admitted read, before windowing. */
21
+ * clears, and it is computed over the full admitted read, before windowing;
22
+ * - a claim is LIVE only after the incarnation's latest kernel session
23
+ * boundary (`launched` | `restarted` | `stopped`): a claim written before
24
+ * the boundary belongs to a session that has ended (WAITING_BOUNDARY_KINDS).
25
+ *
26
+ * Producers write claims as `waiting` rows through `setWaiting` (the CLI's
27
+ * `oats instance waiting` and `oats instance attention`): data
28
+ * `{waitingOnYou, reason?, message?}`, appended only on a change. A claim is
29
+ * evidence for display, never authority: nothing in the kernel acts on it. */
22
30
  import { appendFileSync, closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync } from "node:fs";
23
31
  import { basename, dirname, join } from "node:path";
24
32
 
@@ -26,7 +34,33 @@ export const EVENTS_API = 2;
26
34
  const HOME_LOG = ".oats-events.jsonl";
27
35
  const MAX_BYTES = 4 * 1024 * 1024;
28
36
 
29
- export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "launch-warning", "recomposed"];
37
+ export const EVENT_KINDS = ["spawned", "launched", "restarted", "stopped", "stop-refused", "retire-planned", "retired", "worktree-retained", "worktree-removed", "branch-deleted", "child-spawn-refused", "launch-warning", "recomposed", "waiting"];
38
+
39
+ /** Kernel rows that end or begin a session: a waiting claim older than the
40
+ * incarnation's latest one is not live. */
41
+ export const WAITING_BOUNDARY_KINDS = ["launched", "restarted", "stopped"];
42
+ /** The closed set of reasons a positive claim carries. */
43
+ export const WAITING_REASONS = ["permission", "question", "attention"];
44
+ /** A producer id: what `--producer` accepts (`kernel` is reserved). */
45
+ export const WAITING_PRODUCER_RE = /^[a-z0-9][a-z0-9._/-]{0,63}$/;
46
+ export const WAITING_MESSAGE_MAX = 200;
47
+ /** Characters a claim's message may not contain (the maintainer's set): control
48
+ * characters (Cc: C0, DEL, C1), the Unicode line and paragraph separators, the bidi
49
+ * embeddings, overrides and isolates (U+202A-202E, U+2066-2069), the invisible hiders
50
+ * U+200B, U+2060 and U+FEFF, and the tag characters (U+E0000-E007F). Everything else is
51
+ * allowed, ZWJ and ZWNJ (U+200C/D, emoji sequences, Persian and Indic text) and the marks
52
+ * LRM, RLM and ALM (U+200E/F, U+061C) included. The Desktop uses the identical set. */
53
+ const WAITING_MESSAGE_REFUSED = /[\p{Cc}\u2028\u2029\u202A-\u202E\u2066-\u2069\u200B\u2060\uFEFF\u{E0000}-\u{E007F}]/u;
54
+ /** A claim's message: a non-empty string of 1 to 200 code points with none of
55
+ * WAITING_MESSAGE_REFUSED. The writer refuses anything else; the reader turns a stored
56
+ * message that fails it (a hand-edited log) into null. */
57
+ export function validWaitingMessage(m) {
58
+ return typeof m === "string" && m.length > 0 && [...m].length <= WAITING_MESSAGE_MAX && !WAITING_MESSAGE_REFUSED.test(m);
59
+ }
60
+ // A reason read back from a log is shown as given only when it is one of the
61
+ // closed set; anything else reads as null, and the claim still counts.
62
+ /** A stored reason outside the closed set (a hand-edited or foreign row) reads as null. */
63
+ const readReason = (r) => (WAITING_REASONS.includes(r) ? r : null);
30
64
 
31
65
  function workspaceLogPath(home) {
32
66
  // <workspace>/.agents/events/<agent>--<instance>.jsonl — deployment-private
@@ -43,9 +77,12 @@ export function incarnationOf(home) {
43
77
 
44
78
  /** Append one typed event. Never throws into the caller's action: an event is
45
79
  * evidence, not authority — a failed write is reported in the return value. */
46
- export function appendEvent(home, event, { workspaceOnly = false, incarnation } = {}) {
80
+ export function appendEvent(home, event, { workspaceOnly = false, incarnation, at } = {}) {
47
81
  if (!EVENT_KINDS.includes(event.kind)) return { ok: false, reason: `unknown event kind ${event.kind}` };
48
- const row = { eventsApi: EVENTS_API, at: new Date().toISOString(), instance: basename(home), home, incarnation: incarnation === undefined ? incarnationOf(home) : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
82
+ // `at`: the time the fact became true, when it is recorded later (a start
83
+ // boundary reconciled from its receipt); readers order rows by it.
84
+ if (at !== undefined && (typeof at !== "string" || !Number.isFinite(Date.parse(at)))) return { ok: false, reason: `invalid event time ${at}` };
85
+ const row = { eventsApi: EVENTS_API, at: at ?? new Date().toISOString(), instance: basename(home), home, incarnation: incarnation === undefined ? incarnationOf(home) : incarnation, producer: event.producer || "kernel", kind: event.kind, ...(event.data !== undefined ? { data: event.data } : {}) };
49
86
  const line = JSON.stringify(row) + "\n";
50
87
  const results = [];
51
88
  // Retirement fingerprints the home against its baseline and preserves any
@@ -96,32 +133,68 @@ function readLog(path, label) {
96
133
  return { rows, unreadable, source: { path: label, status: tail ? "tail" : "ok", bytes: st.size } };
97
134
  }
98
135
 
99
- /** Events for one instance ADDRESS, newest last, from both logs, with a bounded
100
- * window; integrity is reported independently of the selected rows. */
101
- export function readEvents(home, { limit = 200, since = null } = {}) {
136
+ /** The ADDRESS's rows from the given log reads: foreign rows dropped and
137
+ * counted, oldest first. The logs are merged as a multiset union: a row in
138
+ * both logs (the same fact, written to each) is kept once, but rows that
139
+ * genuinely repeat within one log (a set, a clear and the same set again in
140
+ * one millisecond) are all kept, as many times as the log holding the most
141
+ * copies has them. */
142
+ function admit(home, reads) {
102
143
  const instance = basename(home);
103
- const incarnation = incarnationOf(home);
104
- const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
105
- const seen = new Set(); const rows = []; let foreign = 0;
106
- for (const r of [...a.rows, ...b.rows]) {
107
- if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
108
- const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
109
- if (seen.has(k)) continue; seen.add(k); rows.push({ ...r, incarnation: r.incarnation ?? null });
144
+ const admitted = new Map(); const rows = []; let foreign = 0;
145
+ for (const read of reads) {
146
+ const inThisLog = new Map();
147
+ for (const r of read.rows) {
148
+ if (r.instance !== instance || r.home !== home) { foreign++; continue; } // another address's row in this log: history of THIS address only
149
+ const k = `${r.producer ?? "kernel"}|${r.at}|${r.kind}|${r.incarnation ?? ""}|${JSON.stringify(r.data ?? null)}`; // same facts from two producers are two rows
150
+ const n = (inThisLog.get(k) ?? 0) + 1; inThisLog.set(k, n);
151
+ if (n <= (admitted.get(k) ?? 0)) continue; // this copy is already in from another log
152
+ admitted.set(k, n); rows.push({ ...r, incarnation: r.incarnation ?? null });
153
+ }
110
154
  }
111
155
  rows.sort((x, y) => x.at.localeCompare(y.at));
112
- // Waiting: a producer STATE for the CURRENT incarnation, decided by that
113
- // producer's latest row that carries the field, over the FULL admitted read.
114
- // An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
115
- // unknown stays unknown, it never resurrects an earlier incarnation's positive.
156
+ return { rows, foreign };
157
+ }
158
+
159
+ /** Waiting: a producer STATE for the CURRENT incarnation, decided by that
160
+ * producer's latest row that carries the field, over the FULL admitted read.
161
+ * An UNKNOWN current incarnation (unreadable instance.json) admits NO claim:
162
+ * unknown stays unknown, it never resurrects an earlier incarnation's
163
+ * positive. A row older than the incarnation's latest kernel session
164
+ * boundary belongs to an ended session and is not a claim at all, nor is a
165
+ * row whose producer is neither `kernel` nor a valid producer id. */
166
+ function claimsOf(rows, incarnation) {
116
167
  const claims = new Map();
117
- for (const r of (incarnation === null ? [] : rows)) {
118
- if (r.incarnation !== incarnation) continue;
168
+ if (incarnation === null) return claims;
169
+ const current = rows.filter((r) => r.incarnation === incarnation);
170
+ // Positional: rows are time-sorted with a stable sort, so rows of the same
171
+ // millisecond keep their append order, and a claim appended before the
172
+ // boundary in that millisecond is still before it.
173
+ const boundary = current.findLastIndex((r) => (r.producer ?? "kernel") === "kernel" && WAITING_BOUNDARY_KINDS.includes(r.kind));
174
+ for (const r of current.slice(boundary + 1)) {
119
175
  if (!r.data || typeof r.data.waitingOnYou !== "boolean") continue;
120
176
  const p = r.producer ?? "kernel";
121
- claims.set(p, r.data.waitingOnYou ? { producer: p, waiting: true, since: r.at, reason: typeof r.data.reason === "string" ? r.data.reason : null } : { producer: p, waiting: false, since: r.at, reason: null });
177
+ // A producer the writer would refuse (a hand-edited log) is never shown as one.
178
+ if (p !== "kernel" && !(typeof p === "string" && WAITING_PRODUCER_RE.test(p))) continue;
179
+ claims.set(p, r.data.waitingOnYou
180
+ ? { producer: p, waiting: true, since: r.at, reason: readReason(r.data.reason), message: validWaitingMessage(r.data.message) ? r.data.message : null }
181
+ : { producer: p, waiting: false, since: r.at, reason: null, message: null });
122
182
  }
183
+ return claims;
184
+ }
185
+ const positiveOf = (claims) => [...claims.values()].filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
186
+ const waitingShape = (c) => (c ? { since: c.since, producer: c.producer, reason: c.reason, message: c.message } : null);
187
+
188
+ /** Events for one instance ADDRESS, newest last, from both logs, with a bounded
189
+ * window; integrity is reported independently of the selected rows. */
190
+ export function readEvents(home, { limit = 200, since = null } = {}) {
191
+ const instance = basename(home);
192
+ const incarnation = incarnationOf(home);
193
+ const a = readLog(join(home, HOME_LOG), "home"), b = readLog(workspaceLogPath(home), "workspace");
194
+ const { rows, foreign } = admit(home, [a, b]);
195
+ const claims = claimsOf(rows, incarnation);
123
196
  const waitingClaims = [...claims.values()];
124
- const positive = waitingClaims.filter((c) => c.waiting).sort((x, y) => x.since.localeCompare(y.since)).at(-1) ?? null;
197
+ const positive = positiveOf(claims);
125
198
  const filtered = since ? rows.filter((r) => r.at > since) : rows;
126
199
  const window = filtered.slice(-limit);
127
200
  const last = window.at(-1) ?? null;
@@ -130,13 +203,95 @@ export function readEvents(home, { limit = 200, since = null } = {}) {
130
203
  truncated: filtered.length > window.length || a.source.status === "tail" || b.source.status === "tail",
131
204
  integrity: { unreadableRows: a.unreadable + b.unreadable, foreignRows: foreign, sources: [a.source, b.source] },
132
205
  events: window, lastEvent: last ? { kind: last.kind, at: last.at, producer: last.producer ?? "kernel", incarnation: last.incarnation } : null,
133
- waitingOnYou: positive ? { since: positive.since, producer: positive.producer, reason: positive.reason } : null,
206
+ waitingOnYou: waitingShape(positive),
134
207
  waitingClaims,
135
208
  notes: [
136
209
  "events are producer-attributed facts written by the kernel action that made them true; nothing is inferred from transcripts or task files",
137
210
  "this is the ADDRESS's history: rows tagged with an earlier incarnation belong to a previous instance at this address",
138
211
  "waitingOnYou is null unless a producer reported it for the current incarnation — null means unknown, not 'not waiting'; an explicit false clears that producer's claim; an unknown current incarnation (null) admits no claim at all",
212
+ "a claim older than the incarnation's latest kernel launched, restarted or stopped row belongs to an ended session and is not counted",
139
213
  "integrity counts torn and foreign rows and names each source's status; truncated is true when the window cut rows or a source was read as a tail",
140
214
  ],
141
215
  };
142
216
  }
217
+
218
+ /** The instance's live waiting state, `{since, producer, reason, message}` or
219
+ * null: what `oats status` rows and `oats session inspect` carry. The same
220
+ * bounded read and the same claim rule as readEvents, over the home log only
221
+ * (the workspace log only when the home log is absent): every row a producer
222
+ * or a session boundary writes goes to both. */
223
+ export function liveWaiting(home) {
224
+ const incarnation = incarnationOf(home);
225
+ if (incarnation === null) return null;
226
+ let log = readLog(join(home, HOME_LOG), "home");
227
+ if (log.source.status === "absent") log = readLog(workspaceLogPath(home), "workspace");
228
+ return waitingShape(positiveOf(claimsOf(admit(home, [log]).rows, incarnation)));
229
+ }
230
+
231
+ const waitingError = (code, message) => Object.assign(new Error(message), { code });
232
+
233
+ /** Set or clear one producer's waiting claim on an instance home; appends a
234
+ * `waiting` row only when the producer's LIVE claim changes (a positive set
235
+ * whose reason or message differs is a change; a clear of a claim that is
236
+ * not positive is not). Each log is judged on its own, so a row that reached
237
+ * only one log earlier is repaired by the next call rather than hidden by the
238
+ * other log; success means both logs took the row. Validates its input
239
+ * (E_BAD_ARGS); a home without a readable instance.json is
240
+ * E_SESSION_UNKNOWN; a write either log refused is E_EVENTS_FAILED (a retry
241
+ * repairs it). Evidence, not authority: nothing else is touched. */
242
+ export function setWaiting(home, { producer, waiting, reason, message } = {}) {
243
+ if (typeof producer !== "string" || !WAITING_PRODUCER_RE.test(producer)) throw waitingError("E_BAD_ARGS", `--producer must match ${WAITING_PRODUCER_RE.source}`);
244
+ if (producer === "kernel") throw waitingError("E_BAD_ARGS", "--producer kernel is reserved for the kernel's own events");
245
+ if (typeof waiting !== "boolean") throw waitingError("E_BAD_ARGS", "waiting must be set or clear");
246
+ if (waiting) {
247
+ if (!WAITING_REASONS.includes(reason)) throw waitingError("E_BAD_ARGS", `--reason must be one of ${WAITING_REASONS.join(", ")}`);
248
+ if (message !== undefined && !validWaitingMessage(message)) throw waitingError("E_BAD_ARGS", `--message must be one line of 1 to ${WAITING_MESSAGE_MAX} characters with no control character, line separator, bidi control (U+202A-202E, U+2066-2069), U+200B, U+2060, U+FEFF or tag character`);
249
+ } else {
250
+ if (reason !== undefined) throw waitingError("E_BAD_ARGS", "--reason is for set, not clear");
251
+ if (message !== undefined) throw waitingError("E_BAD_ARGS", "--message is for set, not clear");
252
+ }
253
+ const incarnation = incarnationOf(home);
254
+ if (incarnation === null) throw waitingError("E_SESSION_UNKNOWN", `${home} is not an instance home (no readable instance.json)`);
255
+ const live = [readLog(join(home, HOME_LOG), "home"), readLog(workspaceLogPath(home), "workspace")]
256
+ .map((log) => { const c = claimsOf(admit(home, [log]).rows, incarnation).get(producer); return c?.waiting ? c : null; });
257
+ const agrees = (c) => (waiting ? !!c && c.reason === reason && c.message === (message ?? null) : !c);
258
+ const answer = (changed, claim) => ({ eventsApi: EVENTS_API, instance: basename(home), home, producer, changed, waitingOnYou: waitingShape(claim) });
259
+ if (live.every(agrees)) return answer(false, waiting ? live[0] : null);
260
+ const data = waiting ? { waitingOnYou: true, reason, ...(message !== undefined ? { message } : {}) } : { waitingOnYou: false };
261
+ const res = appendEvent(home, { producer, kind: "waiting", data }, { incarnation });
262
+ const failed = (res.results || []).filter((r) => !r.ok);
263
+ if (!res.ok || failed.length) throw waitingError("E_EVENTS_FAILED", `could not record the waiting claim in ${failed.map((r) => `${r.path} (${r.reason})`).join(", ") || res.reason}; retry to complete it`);
264
+ return answer(true, waiting ? { producer, since: res.row.at, reason, message: message ?? null } : null);
265
+ }
266
+
267
+ /** The kernel session boundary of one start receipt: a `launched` row tagged
268
+ * `startId`, dated at the receipt's `startedAt`, written once to each log. A
269
+ * start records it as soon as its session exists; recovering an interrupted
270
+ * start (its receipt adopted later) completes it: a log that already has the
271
+ * row is left alone, a log missing it gets a copy of the same row (same time,
272
+ * same data), and only when neither has it is the row made, still at the
273
+ * launch time, so a claim the new session made since is kept. `ok` is true
274
+ * only when every log holds the row; evidence, never authority. */
275
+ export function recordStartBoundary(home, { startId, startedAt, ...data }) {
276
+ const paths = [join(home, HOME_LOG), workspaceLogPath(home)];
277
+ const instance = basename(home);
278
+ const isIt = (r) => r.instance === instance && r.home === home && (r.producer ?? "kernel") === "kernel" && r.kind === "launched" && r.data?.startId === startId;
279
+ const found = paths.map((path) => readLog(path, "log").rows.find(isIt) ?? null);
280
+ if (found.every(Boolean)) return { ok: true, existed: true };
281
+ const existing = found.find(Boolean);
282
+ if (!existing) {
283
+ const res = appendEvent(home, { kind: "launched", data: { ...data, startId } }, { at: startedAt });
284
+ return { ...res, ok: res.ok && (res.results || []).every((r) => r.ok) };
285
+ }
286
+ // The row exactly as the other log holds it: same time, incarnation and data.
287
+ const line = JSON.stringify(existing) + "\n";
288
+ const results = paths.filter((_, i) => !found[i]).map((path) => {
289
+ try {
290
+ if (path.startsWith(home) && !existsSync(home)) return { path, ok: false, reason: "home absent" };
291
+ mkdirSync(dirname(path), { recursive: true });
292
+ appendFileSync(path, line);
293
+ return { path, ok: true };
294
+ } catch (e) { return { path, ok: false, reason: e.message }; }
295
+ });
296
+ return { ok: results.every((r) => r.ok), repaired: true, row: existing, results };
297
+ }