@awebai/oats 0.22.8 → 0.22.9
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/bin/oats.mjs +29 -6
- package/docs/desktop-instance-start.md +34 -0
- package/docs/execution-targets.md +33 -0
- package/docs/release-notes/v0.22.9.md +100 -0
- package/docs/servers.md +4 -1
- package/lib/core.mjs +317 -2
- package/lib/servers.mjs +18 -0
- package/package.json +1 -1
package/bin/oats.mjs
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, assertSafeConfigWriteKey, stripInternalAnnotations, withConfigFile, packagedInject, teamAgentRoots,
|
|
32
32
|
findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, listCapabilityAgents, workspaceOf,
|
|
33
33
|
ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
|
|
34
|
-
spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS,
|
|
34
|
+
spawnInstance, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS,
|
|
35
35
|
} from "../lib/core.mjs";
|
|
36
36
|
import {
|
|
37
37
|
aggregateMissingRequirements, applyFromOasScope, beginRunJournal, discoverMigrationScopes, discoverOasScopes, discoverWorkspaceScopes, planFromOasScope,
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
assertNoSymlinkedParents, copyFileAtomic, writeFileAtomic,
|
|
40
40
|
runRequirementInstall, selectConfigTemplate, validateConfigTemplate, writeAdoptedTemplate,
|
|
41
41
|
} from "../lib/packages.mjs";
|
|
42
|
-
import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
|
|
42
|
+
import { attachArgv, checkRemote, forgetSnapshot, getServer, inspectRemote, startRemote, listSnapshots, readServers, rosterGroups, routeCommand, targetOf, validateServer, writeServers, SERVERS_FILE } from "../lib/servers.mjs";
|
|
43
43
|
import { spawnSync as spawnSyncProc } from "node:child_process";
|
|
44
44
|
|
|
45
45
|
const args = process.argv.slice(2);
|
|
@@ -2857,12 +2857,16 @@ async function sessionCmd() {
|
|
|
2857
2857
|
return;
|
|
2858
2858
|
}
|
|
2859
2859
|
if (args[1] === "inspect") result = inspectInstanceSession(home);
|
|
2860
|
-
else if (args[1] === "
|
|
2860
|
+
else if (args[1] === "start") {
|
|
2861
|
+
const model = flag("model");
|
|
2862
|
+
if (model === true) throw Object.assign(new Error("--model needs a model id; omit it to keep the recorded model"), { code: "E_BAD_ARGS" });
|
|
2863
|
+
result = startInstanceSession(home, { model: model || undefined });
|
|
2864
|
+
} else if (args[1] === "input") {
|
|
2861
2865
|
const file = flag("text-file");
|
|
2862
2866
|
if (file === true) throw Object.assign(new Error("--text-file needs a path"), { code: "E_BAD_ARGS" });
|
|
2863
2867
|
if (!file && process.stdin.isTTY) throw Object.assign(new Error("provide --text-file or pipe input on stdin"), { code: "E_BAD_ARGS" });
|
|
2864
2868
|
result = inputInstanceSession(home, readFileSync(file || 0, "utf8"));
|
|
2865
|
-
} else throw Object.assign(new Error("usage: oats session inspect|input|attach --home /absolute/home [--text-file path] [--json]"), { code: "E_BAD_ARGS" });
|
|
2869
|
+
} else throw Object.assign(new Error("usage: oats session inspect|input|attach|start --home /absolute/home [--text-file path] [--model id] [--json]"), { code: "E_BAD_ARGS" });
|
|
2866
2870
|
if (JSON_MODE) jsonOk(result); else console.log(JSON.stringify(result, null, 2));
|
|
2867
2871
|
} catch (e) { cmdFail(e.code || "E_SESSION_FAILED", e.message); }
|
|
2868
2872
|
}
|
|
@@ -3157,7 +3161,7 @@ function versionCmd() {
|
|
|
3157
3161
|
// on it (an older CLI without the surface must fail closed with a
|
|
3158
3162
|
// reason, not an argument error). `features`: kernel abilities a peer
|
|
3159
3163
|
// must see before relying on them (retire-home: retire --home).
|
|
3160
|
-
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "roster", "harvest"], features: ["retire-home"] }));
|
|
3164
|
+
console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, runtimes: ["pi", "claude", "codex"], sessionBackends: ["tmux", "herdr"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "roster", "harvest"], features: ["retire-home", "session-start"] }));
|
|
3161
3165
|
return;
|
|
3162
3166
|
}
|
|
3163
3167
|
console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
|
|
@@ -3326,7 +3330,19 @@ function serverRouteCmd() {
|
|
|
3326
3330
|
console.log(`${r.instance || r.home} on ${id}: ${r.present ? `present, ${r.state || "unknown"}` : "not present"}${r.backend ? ` (${r.backend})` : ""}`);
|
|
3327
3331
|
return;
|
|
3328
3332
|
}
|
|
3329
|
-
if (args[1]
|
|
3333
|
+
if (args[1] === "start") {
|
|
3334
|
+
const model = flag("model");
|
|
3335
|
+
if (model === true) bail("E_BAD_ARGS", "--model needs a model id; omit it to keep the recorded model");
|
|
3336
|
+
let out;
|
|
3337
|
+
try { out = startRemote(id, { ...addr, model: model || undefined }); } catch (e) { bail(e.code || "E_SSH", e.message); }
|
|
3338
|
+
if (out.stderr?.trim()) process.stderr.write(out.stderr.endsWith("\n") ? out.stderr : out.stderr + "\n");
|
|
3339
|
+
if (JSON_MODE) { console.log(JSON.stringify(out.envelope, null, 2)); if (!out.envelope.ok) process.exit(1); return; }
|
|
3340
|
+
if (!out.envelope.ok) die(`${id}: ${out.envelope.error?.message || "start failed"} (${out.envelope.error?.code || "E_REMOTE"})`);
|
|
3341
|
+
const r = out.envelope.result;
|
|
3342
|
+
console.log(`Started ${r.instance || r.home} on ${id} (${r.backend}${r.model ? `, model ${r.model}` : ""}, ${r.reused === "pane" ? "in its existing pane" : r.reused === "adopted" ? "adopted the pending session" : "new window"})`);
|
|
3343
|
+
return;
|
|
3344
|
+
}
|
|
3345
|
+
if (args[1] !== "attach") bail("E_USAGE", "--server routes `session inspect`, `session start` and `session attach`; input runs on the execution host (the wake broker calls it there)");
|
|
3330
3346
|
let route;
|
|
3331
3347
|
try { route = attachArgv(id, addr, { skipVersionCheck: args.includes("--print") }); }
|
|
3332
3348
|
catch (e) { bail(e.code || "E_BAD_ARGS", e.message); }
|
|
@@ -3506,11 +3522,18 @@ Usage:
|
|
|
3506
3522
|
oats session inspect|attach --server <id> inspect (envelope) or attach a viewer (ssh PTY) for a
|
|
3507
3523
|
--instance <name> | --home <abs> remote instance over its saved route (--print shows
|
|
3508
3524
|
attach); the server needs oats 0.22.2 or later
|
|
3525
|
+
oats session start --server <id> start a stopped remote instance in its existing home
|
|
3526
|
+
--instance <name> | --home <abs> over its saved route; the server must advertise
|
|
3527
|
+
[--model <m>] [--json] session-start (oats 0.22.9 or later)
|
|
3509
3528
|
oats create <name> [--local] create an agent soul; --local = full
|
|
3510
3529
|
[--description <d>] [--repo <r>] soul under local-agents/ (uncommitted,
|
|
3511
3530
|
[--work <mode>] [--runtime pi|claude|codex] gitignored; same memory + lifecycle)
|
|
3512
3531
|
[--model <m>] [--yolo|--no-yolo] [--instructions-file <f>]
|
|
3513
3532
|
oats session inspect|input|attach --home <absolute-home> [--text-file <path>] [--json]
|
|
3533
|
+
oats session start --home <absolute-home> start a STOPPED instance again in its existing home
|
|
3534
|
+
[--model <m>] [--json] (same identity, worktree, notes and launch env; no
|
|
3535
|
+
spawn hooks); --model replaces the recorded model
|
|
3536
|
+
for this and later starts; a live harness is refused
|
|
3514
3537
|
oats spawn <agent> [--task <text>] spawn an instance (tmux/Herdr; --no-launch
|
|
3515
3538
|
[--purpose <slug>] [--repo <r>] = scaffold only); --instructions-file/
|
|
3516
3539
|
[--parent <instance>] --def-file creates a local agent;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Starting an existing instance
|
|
2
|
+
|
|
3
|
+
An instance keeps its home, identity, work and notes when its harness stops.
|
|
4
|
+
The Desktop roster is the place to return to it:
|
|
5
|
+
|
|
6
|
+
- A running row opens its terminal.
|
|
7
|
+
- A stopped row offers **Start…**. Clicking the row opens the same dialog.
|
|
8
|
+
- The hierarchy's action popover offers **Start…** for a stopped instance.
|
|
9
|
+
- An unknown status is shown as unknown, not as permission to launch another process.
|
|
10
|
+
|
|
11
|
+
The Start dialog names the existing instance, runtime and host. Enter a model
|
|
12
|
+
or leave the field blank to retain its recorded choice. Available local model
|
|
13
|
+
suggestions are advisory; a model ID can also be typed. Start uses the saved
|
|
14
|
+
briefing and state in a new harness conversation; it does not resume an old
|
|
15
|
+
harness conversation ID. After the launch appears in the roster, Desktop
|
|
16
|
+
opens the instance's terminal.
|
|
17
|
+
|
|
18
|
+
If the instance is already running when the dialog checks, its action becomes
|
|
19
|
+
**Open terminal**. A failed or timed-out start requires **Refresh status** before
|
|
20
|
+
another attempt, because the launch may have succeeded before the reply was
|
|
21
|
+
lost. Changing workspaces dismisses the dialog and prevents a delayed launch
|
|
22
|
+
reply from opening a terminal in the wrong workspace.
|
|
23
|
+
|
|
24
|
+
Desktop sends `POST /api/start/<instance>?ws=…&home=…` (and `server=…` for a
|
|
25
|
+
remote instance). The backend resolves that exact roster identity and calls
|
|
26
|
+
`oats session start --home <absolute-home> [--server <id>] [--model <model>] --json`.
|
|
27
|
+
The installed CLI must advertise `session-start`; remote starting also needs
|
|
28
|
+
the remote operation. The execution host checks the actual saved session
|
|
29
|
+
before launch. Desktop does not scaffold a home or execute a launcher itself.
|
|
30
|
+
|
|
31
|
+
Status collection reads each instance's recorded tmux socket and session,
|
|
32
|
+
with one query per socket per collection. A launcher shell with a harness
|
|
33
|
+
child remains running; a fallback shell or dead pane is stopped. Errors that
|
|
34
|
+
prevent a reliable observation remain unknown. Herdr uses its saved target.
|
|
@@ -117,8 +117,41 @@ oats session attach --home /absolute/instance
|
|
|
117
117
|
oats session inspect --home /absolute/instance --json
|
|
118
118
|
oats session input --home /absolute/instance --text-file /path/to/message --json
|
|
119
119
|
printf '%s' 'Check your pending work.' | oats session input --home /absolute/instance --json
|
|
120
|
+
oats session start --home /absolute/instance [--model <id>] --json
|
|
120
121
|
```
|
|
121
122
|
|
|
123
|
+
`start` runs a STOPPED instance again in its existing home: same identity,
|
|
124
|
+
worktree, notes and launch environment, no spawn hooks, no new home. It reuses
|
|
125
|
+
the persisted launch command, on the recorded tmux session and socket or the
|
|
126
|
+
recorded Herdr server, and records the new session target in the instance
|
|
127
|
+
metadata and the independent lifecycle receipt (whose home and work
|
|
128
|
+
fingerprints are untouched, so a later retire still preserves everything
|
|
129
|
+
changed since the original spawn). `--model` replaces the recorded model for
|
|
130
|
+
this and later starts by re-rendering the persisted command; a command shape
|
|
131
|
+
OATS did not generate is refused rather than rewritten. A quarantined home or one whose self-retirement is in progress is refused (`E_INSTANCE_RETIRING`). A live harness is
|
|
132
|
+
refused (`E_SESSION_RUNNING`); a fallback shell with no harness descendant
|
|
133
|
+
and a dead pane restart in that exact pane, a missing window opens again, and
|
|
134
|
+
a lost tmux server after a reboot is recreated on the recorded socket. A state
|
|
135
|
+
that cannot be established refuses (`E_SESSION_UNKNOWN`). Every observation
|
|
136
|
+
happens under a per-home guard, so two starts of one home serialize
|
|
137
|
+
(`E_SESSION_START_BUSY`). Each launch retains `.oats-start-pending.json`
|
|
138
|
+
with its target and unique id. The wrapper writes that id to
|
|
139
|
+
`.oats-start-exited` only when the saved command returns. A shell without
|
|
140
|
+
the matching exit marker is still starting and cannot be respawned by a
|
|
141
|
+
second caller. This also covers shell-only harness initialization; it needs
|
|
142
|
+
no background monitor. The next start reconciles the complete receipt before
|
|
143
|
+
the ordinary metadata check: a
|
|
144
|
+
target that is present is recorded and adopted; an exited target is reconciled
|
|
145
|
+
before restarting, and an unobservable target or malformed receipt refuses
|
|
146
|
+
and keeps the receipt. Recovery is idempotent for an already-recorded launch
|
|
147
|
+
and never silently applies a new model to an
|
|
148
|
+
already-running harness. A never-launched legacy Herdr home without a saved
|
|
149
|
+
server endpoint requires that endpoint to be configured before it can start;
|
|
150
|
+
it does not fall back to tmux.
|
|
151
|
+
The start opens a new harness conversation on the instance's `TASK.md`; the
|
|
152
|
+
instance resumes its work from its own `STATE.md`, as the knowledge protocol
|
|
153
|
+
prescribes.
|
|
154
|
+
|
|
122
155
|
`attach` is interactive and does not accept `--json`. It validates the saved
|
|
123
156
|
endpoint on the execution host, then opens a Herdr terminal viewer or an
|
|
124
157
|
isolated tmux session linked to that agent's window alone. Closing its terminal
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# OATS v0.22.9
|
|
2
|
+
|
|
3
|
+
A stopped instance can be started again in its existing home, from the CLI
|
|
4
|
+
and from Desktop, with the model chosen at that moment; Desktop's running
|
|
5
|
+
status and terminal attachment follow each instance's saved tmux socket.
|
|
6
|
+
|
|
7
|
+
## Start a stopped instance in its existing home
|
|
8
|
+
|
|
9
|
+
`oats session start --home <absolute-home> [--model <id>] [--json]` runs the
|
|
10
|
+
instance's persisted launch command again in the same home: same identity,
|
|
11
|
+
worktree, notes and launch environment, no spawn hooks, no new home. The
|
|
12
|
+
command runs in the recorded tmux session on the recorded socket, or on the
|
|
13
|
+
recorded Herdr server. A fallback shell with no harness descendant and a dead
|
|
14
|
+
pane restart in that exact pane; a missing window opens again; a tmux server
|
|
15
|
+
lost to a reboot is recreated on the recorded socket path. A live harness is
|
|
16
|
+
refused (`E_SESSION_RUNNING`), a state that cannot be established is refused
|
|
17
|
+
(`E_SESSION_UNKNOWN`, including permission failures, which are not treated as
|
|
18
|
+
absence), and a quarantined home or one whose self-retirement is in progress
|
|
19
|
+
is refused (`E_INSTANCE_RETIRING`). Nothing is started by any refusal.
|
|
20
|
+
|
|
21
|
+
`--model` replaces the recorded model for this and later starts. The kernel
|
|
22
|
+
re-renders the persisted command through a parser of the exact shapes spawn
|
|
23
|
+
generates; an existing `--model` value is replaced in place, otherwise the
|
|
24
|
+
pair is inserted after the harness binary, and every other token, including
|
|
25
|
+
capability launch environment and arguments, is kept byte for byte. A command
|
|
26
|
+
OATS did not generate, a duplicate or valueless `--model`, or a preference
|
|
27
|
+
whose provider prefix is incompatible with the recorded runtime is refused
|
|
28
|
+
rather than rewritten or silently defaulted. A runtime model id is passed
|
|
29
|
+
through to the harness as given; the kernel does not validate it against a
|
|
30
|
+
catalog. Omitting the model keeps the recorded one.
|
|
31
|
+
|
|
32
|
+
The start opens a new harness conversation from the instance's saved briefing
|
|
33
|
+
(`TASK.md`) and the instance resumes from its own `STATE.md`, as its
|
|
34
|
+
knowledge protocol prescribes. It is not a native conversation resume.
|
|
35
|
+
|
|
36
|
+
## Recovery and the duplicate guard
|
|
37
|
+
|
|
38
|
+
Every observation a start makes happens under a per-home lock, so two starts
|
|
39
|
+
of one home (a double click, two clients) serialize instead of both seeing
|
|
40
|
+
"stopped" and allocating twice (`E_SESSION_START_BUSY`). The independent
|
|
41
|
+
lifecycle receipt and the instance metadata are updated in that order, the
|
|
42
|
+
receipt keeping its home and work fingerprints so a later retire still
|
|
43
|
+
preserves everything changed since the original spawn.
|
|
44
|
+
|
|
45
|
+
A per-launch receipt carrying a launch id and the actual target is written
|
|
46
|
+
before the harness is launched (a Herdr start allocates its empty pane first)
|
|
47
|
+
and is retained for the launch's lifetime, including after the metadata is
|
|
48
|
+
recorded. The command wrapper writes a matching exit marker only when the
|
|
49
|
+
saved command returns, so a start that observes only shells without that
|
|
50
|
+
marker sees a launch still starting up, or a transient child such as the
|
|
51
|
+
briefing being read, and refuses rather than mistaking it for a fallback
|
|
52
|
+
shell; no background watcher or polling is involved. A start that launched
|
|
53
|
+
but could not record its metadata leaves the receipt in place, and the next
|
|
54
|
+
start reconciles it before the ordinary metadata check: a present target is
|
|
55
|
+
recorded and adopted, an exited one is reconciled and restarted, and an
|
|
56
|
+
unobservable target refuses and keeps the receipt. Recovery is idempotent
|
|
57
|
+
through the launch id recorded in the metadata, so an attempt already
|
|
58
|
+
recorded is not counted again. Every field of the receipt, the command, the
|
|
59
|
+
model, the id, the timestamp and the target, is validated before any
|
|
60
|
+
authority or metadata is touched; a malformed receipt refuses and is kept
|
|
61
|
+
unchanged. A running adopted target never has a newly requested model applied
|
|
62
|
+
silently; that start is refused and says so.
|
|
63
|
+
|
|
64
|
+
## Desktop: start with model choice, and saved-socket status
|
|
65
|
+
|
|
66
|
+
A stopped instance's sidebar row and hierarchy popover offer Start, which
|
|
67
|
+
opens a same-home dialog showing the existing runtime, host and home, with an
|
|
68
|
+
optional model (blank keeps the recorded one). The dialog re-checks status
|
|
69
|
+
before submitting, converts a running instance to Open terminal, and blocks
|
|
70
|
+
explicitly on an unknown state, an old CLI or a remote without a route. It
|
|
71
|
+
calls only the kernel command above and waits for the roster to show the
|
|
72
|
+
instance before opening its saved terminal target. When the launch returns
|
|
73
|
+
but the harness is not observed running, the dialog says the instance may
|
|
74
|
+
have exited and offers a status refresh before any retry, instead of
|
|
75
|
+
assuming the roster is merely late.
|
|
76
|
+
|
|
77
|
+
Status now reads each instance's saved tmux socket, session and window, and
|
|
78
|
+
sees a harness running under its launcher shell; a dead pane or fallback
|
|
79
|
+
shell is stopped and an observation error is unknown, never a guess. The
|
|
80
|
+
terminal transport carries that saved socket end to end (preflight, viewer,
|
|
81
|
+
PTY attachment, resource identity and cleanup), so two instances with the
|
|
82
|
+
same window name on different sockets are distinct and input never lands in
|
|
83
|
+
the wrong one. Viewer sessions left behind by a crash are reclaimed on the
|
|
84
|
+
default server at startup and on each saved socket when a terminal is next
|
|
85
|
+
opened there.
|
|
86
|
+
|
|
87
|
+
## Remote hosts
|
|
88
|
+
|
|
89
|
+
`oats session start --server <id> (--instance <name> | --home <abs>)
|
|
90
|
+
[--model <id>]` routes over the saved route and runs the same command on the
|
|
91
|
+
execution host. The kernel's version probe advertises `session-start` in its
|
|
92
|
+
features and remote lists; the local side reads the server's version probe
|
|
93
|
+
and refuses before the remote start, so nothing is mutated on a host whose
|
|
94
|
+
kernel does not advertise it. A remote host needs this release installed
|
|
95
|
+
there first.
|
|
96
|
+
|
|
97
|
+
## Limitation
|
|
98
|
+
|
|
99
|
+
A never-launched legacy Herdr home has no saved server endpoint; starting it
|
|
100
|
+
refuses with that reason and does not fall back to tmux.
|
package/docs/servers.md
CHANGED
|
@@ -128,7 +128,10 @@ under another soul is observed only.
|
|
|
128
128
|
|
|
129
129
|
- Routed: `spawn`, `retire`, `status`, `okf harvest`, and, against a 0.22.2
|
|
130
130
|
or later server, `session inspect` (the execution host's envelope, relayed;
|
|
131
|
-
a Desktop preflight before attaching) and `session attach
|
|
131
|
+
a Desktop preflight before attaching) and `session attach`; against a
|
|
132
|
+
server whose version probe advertises the `session-start` feature (0.22.9
|
|
133
|
+
or later), `session start` (the execution host starts the stopped instance
|
|
134
|
+
in its saved home; `--model` travels). Session input
|
|
132
135
|
runs on the execution host, where the wake broker calls it. `server roster`
|
|
133
136
|
is local (registrations and saved routes, one status pull per group). The
|
|
134
137
|
version probe's `remote` list names this kernel's remote-side surface
|
package/lib/core.mjs
CHANGED
|
@@ -32,11 +32,11 @@ import {
|
|
|
32
32
|
} from "node:fs";
|
|
33
33
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
34
34
|
import { homedir, tmpdir } from "node:os";
|
|
35
|
-
import { createHash } from "node:crypto";
|
|
35
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
36
36
|
import { fileURLToPath } from "node:url";
|
|
37
37
|
import { attachSessionTarget } from "./session-viewer.mjs";
|
|
38
38
|
import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
|
|
39
|
-
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget } from "./herdr.mjs";
|
|
39
|
+
import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot } from "./herdr.mjs";
|
|
40
40
|
|
|
41
41
|
export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
|
|
42
42
|
/** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
|
|
@@ -6448,6 +6448,321 @@ export function inputInstanceSession(home, text) {
|
|
|
6448
6448
|
catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
|
|
6449
6449
|
}
|
|
6450
6450
|
|
|
6451
|
+
// ---------------------------------------------------------------- session start
|
|
6452
|
+
|
|
6453
|
+
/** The exact prompt token spawn renders for claude and codex launches. */
|
|
6454
|
+
const LAUNCH_PROMPT_TOKEN = '"$(cat TASK.md)"';
|
|
6455
|
+
|
|
6456
|
+
/** Tokenize a persisted OATS launch command. The grammar is exactly what
|
|
6457
|
+
* spawn renders: space-separated tokens that are env assignments NAME='v',
|
|
6458
|
+
* single-quoted words ('...' with '\'' escapes, i.e. shq output), bare words
|
|
6459
|
+
* with no shell metacharacters (flags and capability launch args), the bare
|
|
6460
|
+
* `--` separator, or the exact prompt token "$(cat TASK.md)". Anything else
|
|
6461
|
+
* is refused with its position: the command was not one OATS
|
|
6462
|
+
* generated, and rewriting it would be guessing. Re-rendering joins the
|
|
6463
|
+
* tokens' original text, so untouched tokens are byte-identical. */
|
|
6464
|
+
export function parseLaunchCommand(command) {
|
|
6465
|
+
if (typeof command !== "string" || !command.trim() || command.includes("\0")) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", "instance has no valid persisted launch command to start from");
|
|
6466
|
+
const bad = (at, why) => { throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `persisted launch command is not a shape this kernel re-renders (${why} at offset ${at}); inspect the saved command in instance.json before starting this home manually`); };
|
|
6467
|
+
const tokens = [];
|
|
6468
|
+
const n = command.length;
|
|
6469
|
+
let i = 0;
|
|
6470
|
+
while (i < n) {
|
|
6471
|
+
if (command[i] === " ") { i++; continue; }
|
|
6472
|
+
const start = i;
|
|
6473
|
+
if (command.startsWith(LAUNCH_PROMPT_TOKEN, i)) {
|
|
6474
|
+
i += LAUNCH_PROMPT_TOKEN.length;
|
|
6475
|
+
if (i < n && command[i] !== " ") bad(start, "text glued to the prompt token");
|
|
6476
|
+
tokens.push({ kind: "prompt", text: LAUNCH_PROMPT_TOKEN });
|
|
6477
|
+
continue;
|
|
6478
|
+
}
|
|
6479
|
+
let envName;
|
|
6480
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)='/.exec(command.slice(i));
|
|
6481
|
+
if (m) { envName = m[1]; i += envName.length + 1; }
|
|
6482
|
+
if (command[i] === "'") {
|
|
6483
|
+
i++;
|
|
6484
|
+
let value = "";
|
|
6485
|
+
for (;;) {
|
|
6486
|
+
if (i >= n) bad(start, "unterminated single quote");
|
|
6487
|
+
if (command[i] === "'") {
|
|
6488
|
+
if (command.startsWith("'\\''", i)) { value += "'"; i += 4; continue; }
|
|
6489
|
+
i++; break;
|
|
6490
|
+
}
|
|
6491
|
+
value += command[i++];
|
|
6492
|
+
}
|
|
6493
|
+
if (i < n && command[i] !== " ") bad(start, "text glued to a quoted token");
|
|
6494
|
+
const text = command.slice(start, i);
|
|
6495
|
+
tokens.push(envName ? { kind: "env", name: envName, value, text } : { kind: "word", value, quoted: true, text });
|
|
6496
|
+
continue;
|
|
6497
|
+
}
|
|
6498
|
+
if (envName) bad(start, "unquoted env value");
|
|
6499
|
+
while (i < n && command[i] !== " ") {
|
|
6500
|
+
if (/[\s'"$`\\;|&<>(){}*?~#]/.test(command[i])) bad(start, "shell metacharacter outside quotes");
|
|
6501
|
+
i++;
|
|
6502
|
+
}
|
|
6503
|
+
const word = command.slice(start, i);
|
|
6504
|
+
tokens.push(word === "--" ? { kind: "sep", text: word } : { kind: "word", value: word, quoted: false, text: word });
|
|
6505
|
+
}
|
|
6506
|
+
let binary = -1;
|
|
6507
|
+
for (let k = 0; k < tokens.length; k++) {
|
|
6508
|
+
if (tokens[k].kind === "env") { if (binary >= 0) bad(0, "env assignment after the binary"); continue; }
|
|
6509
|
+
if (binary < 0) { if (tokens[k].kind !== "word" || !tokens[k].quoted) bad(0, "no quoted binary after the env prefix"); binary = k; }
|
|
6510
|
+
}
|
|
6511
|
+
if (binary < 0) bad(0, "no binary");
|
|
6512
|
+
let modelIndex = -1;
|
|
6513
|
+
for (let k = binary + 1; k < tokens.length; k++) {
|
|
6514
|
+
if (tokens[k].kind === "sep") break;
|
|
6515
|
+
if (tokens[k].kind === "word" && !tokens[k].quoted && tokens[k].value === "--model") {
|
|
6516
|
+
const v = tokens[k + 1];
|
|
6517
|
+
if (!v || v.kind !== "word" || v.value.startsWith("-")) bad(0, "--model without a value");
|
|
6518
|
+
if (modelIndex >= 0) bad(0, "duplicate --model options");
|
|
6519
|
+
modelIndex = k + 1;
|
|
6520
|
+
k++;
|
|
6521
|
+
}
|
|
6522
|
+
}
|
|
6523
|
+
return { tokens, binary, modelIndex };
|
|
6524
|
+
}
|
|
6525
|
+
|
|
6526
|
+
export function renderLaunchCommand(tokens) { return tokens.map((t) => t.text).join(" "); }
|
|
6527
|
+
|
|
6528
|
+
/** The persisted command with `model` as its --model value: an existing
|
|
6529
|
+
* --model value is replaced; otherwise the pair is inserted right after the
|
|
6530
|
+
* binary, before any option that could be waiting for a value. Capability
|
|
6531
|
+
* env, flags, launch args and the prompt expression are untouched. */
|
|
6532
|
+
export function withLaunchModel(command, model) {
|
|
6533
|
+
const { tokens, binary, modelIndex } = parseLaunchCommand(command);
|
|
6534
|
+
const valueToken = { kind: "word", value: model, quoted: true, text: shq(model) };
|
|
6535
|
+
if (modelIndex >= 0) tokens[modelIndex] = valueToken;
|
|
6536
|
+
else tokens.splice(binary + 1, 0, { kind: "word", value: "--model", quoted: false, text: "--model" }, valueToken);
|
|
6537
|
+
return renderLaunchCommand(tokens);
|
|
6538
|
+
}
|
|
6539
|
+
|
|
6540
|
+
function tmuxOn(socket, args, io) {
|
|
6541
|
+
return (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
|
6542
|
+
}
|
|
6543
|
+
|
|
6544
|
+
function writeJsonAtomic(path, value, mode) {
|
|
6545
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
6546
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", mode !== undefined ? { mode } : undefined);
|
|
6547
|
+
renameSync(tmp, path);
|
|
6548
|
+
}
|
|
6549
|
+
|
|
6550
|
+
/** Start a stopped instance again in its existing home: no new home, no
|
|
6551
|
+
* spawn hooks, no identity work. The persisted launch command runs in the
|
|
6552
|
+
* recorded tmux session (on the recorded socket) or Herdr server, the
|
|
6553
|
+
* instance metadata and the independent retirement receipt get the new
|
|
6554
|
+
* session target (the receipt's home and work fingerprints are untouched,
|
|
6555
|
+
* so retire still preserves everything changed since the original spawn),
|
|
6556
|
+
* and the wake broker's session linkage follows through those receipts.
|
|
6557
|
+
*
|
|
6558
|
+
* Every observation happens under a per-home lock (E_SESSION_START_BUSY for
|
|
6559
|
+
* a concurrent start), so two callers cannot both see "stopped" and
|
|
6560
|
+
* allocate twice. Refusals, before any mutation: unknown or unmanaged home,
|
|
6561
|
+
* a quarantined home or one whose self-retirement marker is present, a
|
|
6562
|
+
* receipt that disagrees with the metadata (the same rule retire and
|
|
6563
|
+
* session use), a session whose state cannot be
|
|
6564
|
+
* established, a live harness (E_SESSION_RUNNING), a model the recorded
|
|
6565
|
+
* runtime cannot use, and a command shape this kernel does not re-render.
|
|
6566
|
+
* A fallback shell with no harness descendant and a dead pane restart in
|
|
6567
|
+
* that exact pane; a missing window and a lost tmux server after a reboot
|
|
6568
|
+
* allocate again (the server on the same socket path).
|
|
6569
|
+
*
|
|
6570
|
+
* Every start retains .oats-start-pending.json naming its target and id.
|
|
6571
|
+
* Its wrapper writes the matching .oats-start-exited marker on command
|
|
6572
|
+
* exit, so shells during startup cannot be mistaken for fallback prompts.
|
|
6573
|
+
* There is no background monitor. The next start
|
|
6574
|
+
* reconciles that receipt BEFORE the ordinary metadata/receipt equality
|
|
6575
|
+
* gate: a target that is present (or a retained dead pane) is recorded and
|
|
6576
|
+
* adopted, an exited target has its metadata reconciled before restarting,
|
|
6577
|
+
* and a target that cannot be observed refuses and keeps the receipt. */
|
|
6578
|
+
export function startInstanceSession(home, o = {}) {
|
|
6579
|
+
if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
|
|
6580
|
+
const realHome = realPathOrNearest(home);
|
|
6581
|
+
const metaPath = join(realHome, "instance.json");
|
|
6582
|
+
if (!existsSync(metaPath)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `${realHome} is not an OATS instance home (no instance.json); nothing was started`);
|
|
6583
|
+
const lock = join(realHome, ".oats-start.lock");
|
|
6584
|
+
const pendingPath = join(realHome, ".oats-start-pending.json");
|
|
6585
|
+
const exitedPath = join(realHome, ".oats-start-exited");
|
|
6586
|
+
const readMeta = () => { try { return JSON.parse(readFileSync(metaPath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read ${metaPath}: ${e.message}`); } };
|
|
6587
|
+
const lostTmuxServer = (e) => /no server running on |(?:error connecting to|failed to connect to) .*(?:No such file or directory|Connection refused)/i.test(String(e.stderr ?? e.message ?? ""));
|
|
6588
|
+
const launchFailure = (backend, error) => {
|
|
6589
|
+
// execFileSync errors embed argv (including capability environment) in
|
|
6590
|
+
// message; backend stderr can echo it too. Neither belongs in the API.
|
|
6591
|
+
const reason = error.code === "ENOENT" ? "backend executable unavailable"
|
|
6592
|
+
: error.code === "ETIMEDOUT" || error.signal === "SIGTERM" ? "backend command timed out" : "backend command failed";
|
|
6593
|
+
return oatsError("E_SESSION_START_FAILED", `${backend} start of ${basename(realHome)} could not be confirmed (${reason}); inspect the recorded session before retrying. Launch evidence is retained in ${pendingPath}`);
|
|
6594
|
+
};
|
|
6595
|
+
// The independent receipt first (retire and session consult it), then the
|
|
6596
|
+
// mutable metadata; both tmp+rename. A failure between them is what the
|
|
6597
|
+
// pending receipt exists for.
|
|
6598
|
+
const record = (meta, { id, backend, target, model, command, startedAt, reused }, clearPending = true) => {
|
|
6599
|
+
const baselinePath = retirementBaselinePath(realHome);
|
|
6600
|
+
let baseline;
|
|
6601
|
+
try { baseline = JSON.parse(readFileSync(baselinePath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or unreadable for ${realHome}: ${e.message}`); }
|
|
6602
|
+
if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== realHome || !runtimeAuthorityOf(baseline)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is invalid for ${realHome}`);
|
|
6603
|
+
baseline.runtime = backend === "herdr"
|
|
6604
|
+
? { launched: true, sessionTarget: target }
|
|
6605
|
+
: { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
|
|
6606
|
+
writeJsonAtomic(baselinePath, baseline, 0o600);
|
|
6607
|
+
if (o.io?.failBeforeMetadataWrite) throw new Error("injected metadata write failure");
|
|
6608
|
+
const recorded = meta.startId === id;
|
|
6609
|
+
const restarts = (Array.isArray(meta.restarts) ? meta.restarts : []).slice(recorded ? -20 : -19);
|
|
6610
|
+
if (!recorded) restarts.push({ startedAt, model: model ?? null, reused });
|
|
6611
|
+
const next = { ...meta, model, command, launched: true, startId: id, restarts, restartCount: (meta.restartCount || 0) + (recorded ? 0 : 1) };
|
|
6612
|
+
if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
|
|
6613
|
+
else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
|
|
6614
|
+
writeJsonAtomic(metaPath, next);
|
|
6615
|
+
if (clearPending) rmSync(pendingPath, { force: true });
|
|
6616
|
+
return { instance: meta.instance, agent: meta.agent, home: realHome, runtime: meta.runtime, backend, model: model ?? null, target, startedAt, restartCount: next.restartCount, reused };
|
|
6617
|
+
};
|
|
6618
|
+
try { mkdirSync(lock); }
|
|
6619
|
+
catch (e) {
|
|
6620
|
+
if (e.code === "EEXIST") throw oatsError("E_SESSION_START_BUSY", `another start of ${basename(realHome)} is in progress (${lock}); if no start is running, remove that directory and retry`);
|
|
6621
|
+
throw e;
|
|
6622
|
+
}
|
|
6623
|
+
try {
|
|
6624
|
+
if (existsSync(join(realHome, ".oats-rollback-incomplete.json"))) throw oatsError("E_INSTANCE_RETIRING", `${realHome} is a quarantined home whose cleanup is incomplete; finish its retirement (oats retire) before starting anything there`);
|
|
6625
|
+
// A self-retirement leaves this marker while its detached teardown runs:
|
|
6626
|
+
// the home is about to disappear, so nothing is relaunched into it.
|
|
6627
|
+
if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired (${retirePendingMarkerPath(realHome)} is present); nothing was started`);
|
|
6628
|
+
// 1. Reconcile a pending receipt before the equality gate: it may be the
|
|
6629
|
+
// only record of a session an earlier start allocated.
|
|
6630
|
+
if (existsSync(pendingPath)) {
|
|
6631
|
+
let pending;
|
|
6632
|
+
try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
|
|
6633
|
+
const pt = pending?.target;
|
|
6634
|
+
const validTarget = pt?.backend === "herdr" ? validHerdrTarget(pt)
|
|
6635
|
+
: pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
|
|
6636
|
+
let validCommand = false;
|
|
6637
|
+
try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
|
|
6638
|
+
const validReceipt = validTarget && validCommand
|
|
6639
|
+
&& typeof pending.id === "string" && /^[a-zA-Z0-9-]{1,80}$/.test(pending.id)
|
|
6640
|
+
&& (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
|
|
6641
|
+
&& typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
|
|
6642
|
+
if (!validReceipt) throw oatsError("E_SESSION_UNKNOWN", `an earlier start left an unreadable or invalid receipt at ${pendingPath}; inspect it before retrying; nothing was started`);
|
|
6643
|
+
const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
|
|
6644
|
+
let st;
|
|
6645
|
+
try { st = inspectSessionTarget(pending.target, o.io); }
|
|
6646
|
+
catch (e) {
|
|
6647
|
+
if (pbackend === "tmux" && lostTmuxServer(e)) st = { present: false, state: "stopped" };
|
|
6648
|
+
else throw oatsError("E_SESSION_UNKNOWN", `an earlier start of ${basename(realHome)} recorded a session (${pbackend === "herdr" ? `Herdr pane ${pending.target.paneId}` : `tmux ${pending.target.session}:${pending.target.window} on ${pending.target.socket}`}) that cannot be observed now: ${String(e.stderr ?? e.message ?? "").trim() || e.message}; the receipt ${pendingPath} is kept and nothing was started`);
|
|
6649
|
+
}
|
|
6650
|
+
// A launch may still consist entirely of shells (startup files, a
|
|
6651
|
+
// shell-script harness). Only its own completion marker proves this
|
|
6652
|
+
// is a fallback shell. Never respawn an accepted launch in that gap.
|
|
6653
|
+
if (st.present && st.state === "shell") {
|
|
6654
|
+
const exited = existsSync(exitedPath) && readFileSync(exitedPath, "utf8").trim() === pending.id;
|
|
6655
|
+
if (!exited) throw oatsError("E_SESSION_START_BUSY", `${basename(realHome)} is still starting; refresh its status before retrying`);
|
|
6656
|
+
}
|
|
6657
|
+
// Reconcile even an exited target: the independent baseline may
|
|
6658
|
+
// already name it while metadata still names the old allocation.
|
|
6659
|
+
const meta = readMeta();
|
|
6660
|
+
const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
|
|
6661
|
+
if (st.present && st.state !== "shell") {
|
|
6662
|
+
if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), meta.runtime) !== done.model) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running with its previously requested model; its target was recovered, but the new model was not applied`);
|
|
6663
|
+
if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
|
|
6664
|
+
return done;
|
|
6665
|
+
}
|
|
6666
|
+
}
|
|
6667
|
+
// 2. The ordinary gate and observation, all under the lock.
|
|
6668
|
+
const receipt = instanceSessionTarget(realHome);
|
|
6669
|
+
const meta = readMeta();
|
|
6670
|
+
const runtime = meta.runtime;
|
|
6671
|
+
if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records runtime ${JSON.stringify(runtime)}, which this kernel cannot relaunch`);
|
|
6672
|
+
const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
|
|
6673
|
+
let command = meta.command;
|
|
6674
|
+
let model = meta.model || undefined;
|
|
6675
|
+
if (o.model !== undefined && o.model !== null && String(o.model).trim() !== "") {
|
|
6676
|
+
const resolved = resolveModelPreference(String(o.model), runtime);
|
|
6677
|
+
if (!resolved) throw oatsError("E_MODEL_UNKNOWN", `model preference ${JSON.stringify(o.model)} has no entry usable by runtime ${runtime}; give a ${runtime} model id`);
|
|
6678
|
+
command = withLaunchModel(command, resolved);
|
|
6679
|
+
model = resolved;
|
|
6680
|
+
} else parseLaunchCommand(command);
|
|
6681
|
+
let target = receipt.target;
|
|
6682
|
+
let state = { present: false, state: "not-launched" };
|
|
6683
|
+
let serverGone = false;
|
|
6684
|
+
if (target) {
|
|
6685
|
+
try { state = inspectSessionTarget(target, o.io); }
|
|
6686
|
+
catch (e) {
|
|
6687
|
+
if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
|
|
6688
|
+
else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
|
|
6689
|
+
}
|
|
6690
|
+
}
|
|
6691
|
+
if (state.present && state.state !== "shell") throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
|
|
6692
|
+
const startedAt = new Date().toISOString();
|
|
6693
|
+
const id = randomUUID();
|
|
6694
|
+
const completedCommand = `${command}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
|
|
6695
|
+
let reused = "new";
|
|
6696
|
+
if (backend === "herdr") {
|
|
6697
|
+
if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
|
|
6698
|
+
if (state.present) { reused = "pane"; }
|
|
6699
|
+
else {
|
|
6700
|
+
const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
|
|
6701
|
+
try { herdrSnapshot(base, o.io); }
|
|
6702
|
+
catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
|
|
6703
|
+
target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
|
|
6704
|
+
}
|
|
6705
|
+
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
|
|
6706
|
+
try { launchHerdr(target, `cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
|
|
6707
|
+
catch (e) { throw launchFailure("Herdr", e); }
|
|
6708
|
+
} else {
|
|
6709
|
+
const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
|
|
6710
|
+
const window = target?.window || meta.tmux?.window || meta.instance;
|
|
6711
|
+
let socket = target?.socket || meta.tmux?.socket;
|
|
6712
|
+
const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
|
|
6713
|
+
// A fallback shell (no harness descendant) or a retained dead pane is
|
|
6714
|
+
// the agent's own pane: the command runs there, no other window touched.
|
|
6715
|
+
const inPlace = state.paneId && (state.present || state.state === "stopped");
|
|
6716
|
+
if (inPlace) {
|
|
6717
|
+
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
|
|
6718
|
+
try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, windowCmd], o.io); }
|
|
6719
|
+
catch (e) { throw launchFailure("tmux", e); }
|
|
6720
|
+
reused = "pane";
|
|
6721
|
+
} else {
|
|
6722
|
+
const instancesRoot = dirname(realHome);
|
|
6723
|
+
const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
|
|
6724
|
+
if (!socket) {
|
|
6725
|
+
// Never launched (--no-launch): the default server, as spawn uses.
|
|
6726
|
+
if (!tmuxAlive(session)) {
|
|
6727
|
+
sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
|
|
6728
|
+
shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
|
|
6729
|
+
shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
|
|
6730
|
+
}
|
|
6731
|
+
socket = tmuxSocket(session);
|
|
6732
|
+
} else if (serverGone) {
|
|
6733
|
+
// The recorded server is gone (a reboot): the same socket path again.
|
|
6734
|
+
mkdirSync(dirname(socket), { recursive: true });
|
|
6735
|
+
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
6736
|
+
tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
|
|
6737
|
+
tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
|
|
6738
|
+
}
|
|
6739
|
+
let names = [];
|
|
6740
|
+
try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
|
|
6741
|
+
catch (e) {
|
|
6742
|
+
if (!/can't find session/i.test(String(e.stderr ?? e.message ?? "")) && !lostTmuxServer(e)) throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${String(e.stderr ?? e.message ?? "").trim()}`);
|
|
6743
|
+
tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
|
|
6744
|
+
}
|
|
6745
|
+
if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
|
|
6746
|
+
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
6747
|
+
writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt }, 0o600);
|
|
6748
|
+
try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, windowCmd], o.io); }
|
|
6749
|
+
catch (e) { throw launchFailure("tmux", e); }
|
|
6750
|
+
}
|
|
6751
|
+
target = { backend: "tmux", session, window, socket: resolve(socket) };
|
|
6752
|
+
}
|
|
6753
|
+
// Keep launch evidence until the command exits or the target disappears.
|
|
6754
|
+
// A transient child (for example cat TASK.md) is not proof that startup
|
|
6755
|
+
// has finished. A later start reconciles the receipt without a watcher.
|
|
6756
|
+
try { return record(meta, { id, backend, target, model, command, startedAt, reused }, false); }
|
|
6757
|
+
catch (e) {
|
|
6758
|
+
if (e.code && String(e.code).startsWith("E_")) throw e;
|
|
6759
|
+
throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (${backend === "herdr" ? `Herdr pane ${target.paneId}` : `tmux ${target.session}:${target.window} on ${target.socket}`}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
|
|
6760
|
+
}
|
|
6761
|
+
} finally {
|
|
6762
|
+
rmSync(lock, { recursive: true, force: true });
|
|
6763
|
+
}
|
|
6764
|
+
}
|
|
6765
|
+
|
|
6451
6766
|
function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
|
|
6452
6767
|
const classes = [];
|
|
6453
6768
|
let baseline;
|
package/lib/servers.mjs
CHANGED
|
@@ -614,6 +614,24 @@ export function inspectRemote(serverId, { instance, home } = {}, io = {}) {
|
|
|
614
614
|
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance, home: route.home } } : envelope, stderr, route };
|
|
615
615
|
}
|
|
616
616
|
|
|
617
|
+
/** The kernel version whose probe first advertises the `session-start`
|
|
618
|
+
* feature; the probe's features list is the actual check. */
|
|
619
|
+
export const SESSION_START_REMOTE_VERSION = "0.22.9";
|
|
620
|
+
|
|
621
|
+
/** `session start` on the execution host for a remote instance: the same
|
|
622
|
+
* route resolution as inspect, refused before any mutation when the remote
|
|
623
|
+
* kernel does not advertise session-start, the envelope relayed as is. */
|
|
624
|
+
export function startRemote(serverId, { instance, home, model } = {}, io = {}) {
|
|
625
|
+
const route = resolveRoute(serverId, { instance, home }, "session start");
|
|
626
|
+
const remote = requireSessionRemote(route.target, io);
|
|
627
|
+
if (!remote.features.includes("session-start")) {
|
|
628
|
+
throw serverError("E_REMOTE_INCOMPATIBLE", `remote oats ${remote.version} at ${route.target.sshHost} does not advertise session-start (kernels from ${SESSION_START_REMOTE_VERSION} do); upgrade it there, or start the instance on that host`);
|
|
629
|
+
}
|
|
630
|
+
const args = ["session", "start", "--home", route.home, ...(model ? ["--model", String(model)] : []), "--json"];
|
|
631
|
+
const { envelope, stderr } = runRemote(route.target, args, io);
|
|
632
|
+
return { envelope: envelope.ok ? { ...envelope, result: { ...envelope.result, server: serverId, instance: instance || route.snapshot?.instance || envelope.result.instance } } : envelope, stderr, route };
|
|
633
|
+
}
|
|
634
|
+
|
|
617
635
|
export function attachArgv(serverId, { instance, home } = {}, io = {}) {
|
|
618
636
|
const { target, home: remoteHome } = resolveRoute(serverId, { instance, home }, "session attach");
|
|
619
637
|
if (!io.skipVersionCheck) requireSessionRemote(target, io);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@awebai/oats",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.9",
|
|
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",
|