@awebai/oats 0.25.4 → 0.25.6

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 CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  packageIntegrity, capabilityArtifactIntegrity, verifyCapabilityInstallation, installedCapabilityDir,
32
32
  resolveOatsConfig, resolveWorkMode, composeInstanceAgentsMd, parseYamlNested, assertSafeConfigValue, stripInternalAnnotations, withConfigFile, teamAgentRoots,
33
33
  findTeamAgent, findTeamInstance, findCapabilityAgent, findInstanceHome, findInstanceHomes, listCapabilityAgents, workspaceOf, stopInstanceSession, recomposeInstanceInstructions,
34
- ensureRoot, findRoot, findAgent, listAgents, listInstances, listAgentDefs, createAgent as coreCreateAgent,
34
+ ensureRoot, findRoot, findAgent, listAgents, listInstances, servedIdentityLine, servedIdentityOf, listAgentDefs, createAgent as coreCreateAgent,
35
35
  spawnInstance, spawnInstanceAsync, findModuleCapabilityAgent, capabilityAgentFromDir, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, upsertLocalAgent, defaultRepo, RELATIONS, validateLaunchConfig, resolveLaunchSelection, resolveLaunchExecutable, checkLaunchExecutable, missingLaunchEnvRefs, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_RUNTIMES, LAUNCH_RECIPE_VERSION, parseLaunchCommand, resolveYolo, planLaunch, redactLaunchCommand, restartInstanceSession,
36
36
  } from "../lib/core.mjs";
37
37
  import {
@@ -935,7 +935,9 @@ function computeInspect({ onFail } = {}) {
935
935
  const result = {
936
936
  operationsApi: 1, kernel: OATS_VERSION,
937
937
  scope: { context: ctx, requestedContext: requestedContext === ctx ? null : requestedContext, workspace: roots.length ? workspaceOf(roots[0]) : ctx, team: r.team || null, chain: chain.map((c) => ({ file: c._file, level: c._level, levelKind: levelOf(c._level) })), agentsRoots: roots },
938
- selected: { soul: selectedSoul?.name || null, agentsRoot: selectedSoul?.agentsRoot || null, home: home || null, source: meta ? "snapshot" : "config" },
938
+ selected: { soul: selectedSoul?.name || null, agentsRoot: selectedSoul?.agentsRoot || null, home: home || null, source: meta ? "snapshot" : "config",
939
+ // Decision 27 (K2): the principal this home acts as, from its messaging provider's hook meta.
940
+ ...(meta ? { identity: servedIdentityOf(meta) } : {}) },
939
941
  souls, sources, layers, capabilities, knowledge, snapshot, currentConfig,
940
942
  problems: [...(lockError ? [lockError] : []), ...packagedDiagnostics.map((d) => ({ code: d.code, message: d.message, capability: d.capability })),
941
943
  ...(meta ? snapshotCaps.filter((c) => !mans[c.id]).map((c) => ({ code: "captured-capability-missing", message: `${c.id} was active when this home was composed but no manifest for it is acquired now`, capability: c.id })) : [])],
@@ -947,6 +949,7 @@ function printInspect(result) {
947
949
  const { ctx, selectedSoul, home } = result._print; delete result._print;
948
950
  const { souls, layers, capabilities } = result;
949
951
  console.log(`oats inspect — ${shortPath(ctx)}${selectedSoul ? ` soul ${selectedSoul.name}` : ""}${home ? ` home ${shortPath(home)}` : ""}`);
952
+ if (result.selected?.identity) console.log(` identity: ${servedIdentityLine(result.selected.identity)}`);
950
953
  for (const s of souls) console.log(` soul ${s.name} [${s.kind}${s.capability ? ` ${s.capability}` : ""}] runtime ${s.runtime}${s.model ? ` model ${s.model}` : ""} work ${s.work}${s.editable.fields.length ? "" : " (read-only)"}`);
951
954
  for (const l of LAYERS) console.log(` layer ${l}: ${layers[l].id || (layers[l].disabled ? "disabled" : "none")}${layers[l].provenance ? ` (${layers[l].provenance})` : ""}`);
952
955
  for (const c of capabilities) console.log(` ${c.id}@${c.version || "?"} ${c.health.status}${c.activation.enabled ? ` active:${c.activation.target}` : " inactive"}${c.operations.length ? ` ops: ${c.operations.map((o) => `${o.name}${o.available ? "" : "(unavailable)"}`).join(", ")}` : ""}`);
@@ -2343,6 +2346,7 @@ async function status() {
2343
2346
  for (const i of a.instances) {
2344
2347
  console.log(` • ${i.instance} ${i.retirePending ? "RETIRING" : i.running ? "RUNNING" : "idle"} (branch ${i.branch || "?"}, ${i.work || "?"})`);
2345
2348
  const key = i.home ?? `${a.name}/${i.instance}`;
2349
+ if (i.identity) console.log(` identity: ${servedIdentityLine(i.identity)}`);
2346
2350
  const s = ws?.soul.get(key);
2347
2351
  if (s && (verbose || s.status !== "current")) console.log(` ${soulDriftLine(s, a.name)}`);
2348
2352
  const rows = ws?.drift.get(key) || [];
@@ -3366,7 +3370,7 @@ function versionCmd() {
3366
3370
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3367
3371
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3368
3372
  // never listed.
3369
- 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", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
3373
+ 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", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "session-recompose", "readiness-verify", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity"], workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 1, lifecycleApi: 1, readinessApi: 1, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 1, capturedDispatchApi: 1, capturedDispatchActions: ["inspect", "compose", "command", "operation", "spawn", "trust"] }));
3370
3374
  return;
3371
3375
  }
3372
3376
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -39,7 +39,7 @@
39
39
  import { execFileSync } from "node:child_process";
40
40
  import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
41
41
  import { hostname } from "node:os";
42
- import { join, dirname, resolve, delimiter } from "node:path";
42
+ import { join, dirname, resolve, delimiter, isAbsolute } from "node:path";
43
43
  import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
44
44
  import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/session-readiness.mjs";
45
45
  import { runCapturedNative } from "../lib/captured-native.mjs";
@@ -50,9 +50,11 @@ import { parseBindingJson } from "../lib/binding-wire.mjs";
50
50
  * property of one helper staying correct forever, while argv removes the class.
51
51
  * This hook is a REQUIRED spawn hook, so it gates every spawn, which is reason
52
52
  * enough not to rely on quoting. */
53
- const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false } = {}) => {
53
+ const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false, env: extraEnv, unsetEnv = [] } = {}) => {
54
54
  try {
55
- return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout }).trim();
55
+ const childEnv = extraEnv || unsetEnv.length ? { ...process.env, ...(extraEnv || {}) } : undefined;
56
+ for (const name of unsetEnv) if (childEnv) delete childEnv[name];
57
+ return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout, ...(childEnv ? { env: childEnv } : {}) }).trim();
56
58
  } catch (e) {
57
59
  // execFileSync puts the WHOLE ARGV in e.message ("Command failed: aw team
58
60
  // join <token> …"). This hook's failures are reported by the kernel and land
@@ -113,6 +115,17 @@ const warn = (m) => out({ warning: `oats-aweb: ${String(m).slice(0, 300)}` });
113
115
  * nonzero so the kernel rolls the spawn back. `meta` carries whatever external
114
116
  * state already exists (e.g. a joined identity) so retire can undo it. */
115
117
  const fatal = (m, meta) => out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(m).slice(0, 300)}` }, 1);
118
+ const parseAwJson = (text, what) => {
119
+ const trimmed = String(text ?? "").trim();
120
+ if (!trimmed) throw new Error(`${what} returned no JSON result`);
121
+ try { return JSON.parse(trimmed); } catch { /* may have progress before JSON */ }
122
+ const lines = trimmed.split(/\r?\n/);
123
+ for (let i = 0; i < lines.length; i++) {
124
+ if (!lines[i].trimStart().startsWith("{")) continue;
125
+ try { return JSON.parse(lines.slice(i).join("\n")); } catch { /* keep looking */ }
126
+ }
127
+ throw new Error(`${what} returned no JSON result`);
128
+ };
116
129
 
117
130
  // Any selected snapshot enters the captured consumer BEFORE legacy settings,
118
131
  // root discovery or identity handling. Invalid-present never falls back.
@@ -150,6 +163,15 @@ const deliveryMode = (() => {
150
163
  const v = settings.delivery === undefined || settings.delivery === null || settings.delivery === "" ? "channel" : String(settings.delivery);
151
164
  return v === "session" ? "session" : "channel";
152
165
  })();
166
+ const identitySettings = settings.identity && typeof settings.identity === "object" && !Array.isArray(settings.identity) ? settings.identity : {};
167
+ const identityMode = identitySettings.mode === undefined || identitySettings.mode === null || identitySettings.mode === "" ? "local" : String(identitySettings.mode);
168
+ if (!["local", "global"].includes(identityMode) && ["spawn", "retire"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
169
+ const payloadTeam = () => {
170
+ const fromSettings = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : undefined;
171
+ const fromEnv = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || undefined;
172
+ return { team: fromSettings || fromEnv, payload: fromSettings, env: fromEnv };
173
+ };
174
+ const identityMeta = ({ mode = "local", alias, team, address = null, resident = null, grant }) => ({ mode, alias, team, address: address || null, resident: resident || null, ...(grant ? { grant } : {}) });
153
175
 
154
176
  /**
155
177
  * The aweb root (minting authority). BOUNDED candidates — the deployment's team
@@ -231,6 +253,126 @@ function wakeRegister(instanceHome, identityHome) {
231
253
  function wakeDeregister(instanceHome) {
232
254
  try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
233
255
  }
256
+ const DEFAULT_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send"];
257
+ const residentKeyHint = (name) => `oats-local.yaml settings.oats.aweb.residents.${name || "<name>"}`;
258
+ function resolveResidentCustody(name) {
259
+ if (!name) fatal(`identity.mode "global" requires identity.resident; set ${residentKeyHint("<name>")} to the absolute custody directory for that resident identity`);
260
+ const residents = settings.residents && typeof settings.residents === "object" && !Array.isArray(settings.residents) ? settings.residents : {};
261
+ const custody = residents[name];
262
+ if (typeof custody !== "string" || !isAbsolute(custody) || !existsSync(join(custody, ".aw", "identity.yaml"))) {
263
+ fatal(`identity.mode "global" resident ${JSON.stringify(name)} is not resolvable; set ${residentKeyHint(name)} to an absolute custody directory whose .aw/identity.yaml exists`);
264
+ }
265
+ return custody;
266
+ }
267
+ function grantShow(custody, grantId) {
268
+ const raw = run(["aw", "id", "grant", "show", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
269
+ return parseAwJson(raw, "aw id grant show");
270
+ }
271
+ const revokeMaybeApplied = (error) => /may have applied|context deadline exceeded|timed out|timeout/i.test(String(error?.message || error));
272
+ function revokeGrant(custody, grantId) {
273
+ try {
274
+ const raw = run(["aw", "id", "grant", "revoke", grantId, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
275
+ return parseAwJson(raw, "aw id grant revoke");
276
+ } catch (e) {
277
+ if (revokeMaybeApplied(e)) {
278
+ try {
279
+ const shown = grantShow(custody, grantId);
280
+ const status = String(shown.status || shown.grant?.status || "").toLowerCase();
281
+ if (status === "revoked" || shown.revoked === true) return { grant_id: grantId, status: "revoked", verifiedByShow: true };
282
+ } catch { /* fall through to original revoke error */ }
283
+ }
284
+ throw e;
285
+ }
286
+ }
287
+ function recoverGrantHome(grantHome) {
288
+ try {
289
+ const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
290
+ const scalar = (key) => {
291
+ const m = text.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
292
+ return m ? m[1].trim() : undefined;
293
+ };
294
+ return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at") };
295
+ } catch { return {}; }
296
+ }
297
+ function globalGrantSpawn() {
298
+ const { team, payload, env: envTeam } = payloadTeam();
299
+ if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team (or OATS_TEAM_ID/OATS_TEAM_NAME) before minting a grant");
300
+ const resident = String(identitySettings.resident || "");
301
+ const custody = resolveResidentCustody(resident);
302
+ const grantHome = join(home, ".aweb-identity");
303
+ if (existsSync(grantHome)) fatal(`${grantHome} already exists; refusing to overwrite an existing aweb session grant home`);
304
+ const scopes = Array.isArray(identitySettings.scopes) && identitySettings.scopes.length ? identitySettings.scopes.map(String) : DEFAULT_GRANT_SCOPES;
305
+ const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
306
+ let meta;
307
+ const cleanup = () => { try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ } };
308
+ const failAfterMint = (message, code = 1) => { cleanup(); out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(message).slice(0, 300)}` }, code); };
309
+ try {
310
+ const raw = run(["aw", "id", "grant", "mint", "--scope", scopes.join(","), "--ttl", ttl, "--label", `oats:${instance}`, "--out", grantHome, "--json"], custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
311
+ let minted;
312
+ try { minted = parseAwJson(raw, "aw id grant mint"); }
313
+ catch (parseError) {
314
+ const recovered = recoverGrantHome(grantHome);
315
+ if (recovered.grantId) {
316
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias: resident, team: recovered.team || team, resident, grant: { id: recovered.grantId, expiresAt: recovered.expiresAt || "unknown", scopes } }) };
317
+ try { revokeGrant(custody, recovered.grantId); failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, revoked it, and removed the grant home`); }
318
+ catch (revokeError) { failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, but revoke failed: ${revokeError.message || revokeError}`); }
319
+ }
320
+ throw parseError;
321
+ }
322
+ const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
323
+ const expiresAt = typeof minted.expires_at === "string" ? minted.expires_at : undefined;
324
+ const mintedTeam = typeof minted.team_id === "string" ? minted.team_id : undefined;
325
+ const mintedOut = typeof minted.out === "string" ? minted.out : undefined;
326
+ if (!grantId || !expiresAt || !mintedTeam || !mintedOut) throw new Error("aw id grant mint JSON lacked grant_id, expires_at, team_id, or out");
327
+ if (resolve(mintedOut) !== resolve(grantHome)) throw new Error(`aw id grant mint wrote ${mintedOut}, not ${grantHome}`);
328
+ const alias = typeof minted.alias === "string" && minted.alias ? minted.alias : resident;
329
+ const address = typeof minted.address === "string" && minted.address ? minted.address : null;
330
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes } }) };
331
+ if (mintedTeam !== team) {
332
+ try { revokeGrant(custody, grantId); failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; the grant was revoked and nothing was kept`); }
333
+ catch (e) { failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; revoke failed: ${e.message || e}`); }
334
+ }
335
+ const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
336
+ ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
337
+ : undefined;
338
+ const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: grantHome };
339
+ if (deliveryMode === "session") {
340
+ try { wakeRegister(home, grantHome); }
341
+ catch (e) {
342
+ try { revokeGrant(custody, grantId); failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; grant revoked and grant home removed`); }
343
+ catch (revokeError) { failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
344
+ }
345
+ }
346
+ const warnings = [];
347
+ if (payload && envTeam && payload !== envTeam) warnings.push(`oats-aweb: settings.oats.aweb.team ${payload} differs from OATS team ${envTeam}; using payload team`);
348
+ const deliveryBrief = deliveryMode === "session"
349
+ ? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
350
+ : "";
351
+ out({
352
+ meta,
353
+ env,
354
+ brief: `Comms: you act as resident aweb identity "${alias}" on team ${mintedTeam} through a session grant for ${resident}; scopes: ${scopes.join(", ")}; expires: ${expiresAt}. Root keys are not in this home, and identity lifecycle commands are not yours to run.${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
355
+ ...(launch ? { launch } : {}),
356
+ ...(warnings.length ? { warning: warnings.join(" | ") } : {}),
357
+ });
358
+ } catch (e) {
359
+ if (meta?.identity?.grant?.id) { try { revokeGrant(custody, meta.identity.grant.id); } catch { /* retire compensation gets meta */ } cleanup(); }
360
+ fatal(`identity grant minting failed: ${e.message || e}`, meta);
361
+ }
362
+ }
363
+ function globalGrantRetire(meta) {
364
+ if (meta.delivery === "session") { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
365
+ const id = meta.identity?.grant?.id;
366
+ if (!id) out({ meta: { retired: false, reason: "nothing-to-revoke" } });
367
+ const resident = meta.identity?.resident;
368
+ const custody = resolveResidentCustody(resident);
369
+ try {
370
+ revokeGrant(custody, id);
371
+ out({ meta: { retired: true, identityRevoked: true, grant: id } });
372
+ } catch (e) {
373
+ out({ meta: { retired: false, reason: "grant-revoke-failed", grant: id }, warning: `oats-aweb: grant ${id} was not revoked (${e.message || e}); it still expires at ${meta.identity?.grant?.expiresAt || "its TTL"}` }, 1);
374
+ }
375
+ }
234
376
  const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json");
235
377
  /** The alias a home's .aw/workspace.yaml records under memberships (indented),
236
378
  * or undefined. Read only when the hook has no alias of its own. */
@@ -357,7 +499,7 @@ function retainedSeatSpawn(source, takeOver) {
357
499
  if (takenOver) warnings.push(`oats-aweb: took over the retained identity from ${takenOver} on identity.takeOver: true; if that runtime was still alive there are now two seats with one key — stop the old one`);
358
500
  if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
359
501
  out({
360
- meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, ...(takenOver ? { tookOverFrom: takenOver } : {}) },
502
+ meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team, address: shownAddress || expectedAddress || null }), ...(takenOver ? { tookOverFrom: takenOver } : {}) },
361
503
  ...(env ? { env } : {}),
362
504
  brief: `Comms: you are the retained seat of the existing aweb identity "${alias}" on team ${team} (same did and address as the seat you replace; its contacts, routes and conversations are yours).${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill).`,
363
505
  ...(launch ? { launch } : {}),
@@ -370,7 +512,9 @@ function retainedSeatSpawn(source, takeOver) {
370
512
  }
371
513
 
372
514
  if (event === "spawn") {
373
- if (settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
515
+ if (identityMode === "global" && identitySettings.source) fatal('identity.mode "global" cannot be combined with identity.source; use identity.mode "local" with identity.source for a retained seat, or identity.mode "global" with identity.resident for a resident grant');
516
+ if (identityMode === "global") globalGrantSpawn();
517
+ if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
374
518
  let minted; // external identity, once `aw team join` succeeds
375
519
  const root = awebRoot();
376
520
  if (!root) fatal(`no initialized aweb root (.aw) among the bounded candidates (home, its git repo, context repo, workspace ${process.env.OATS_WORKSPACE || "?"}), so no identity could be minted and this instance would have no messaging — run \`oats aweb setup\` for guided onboarding`);
@@ -380,7 +524,9 @@ if (event === "spawn") {
380
524
  // team happens to be active at mint time — and verify the joined cert matches.
381
525
  // The instance name IS the discoverable alias (the team roster doubles as the
382
526
  // cross-machine instance directory).
383
- let team = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME;
527
+ const resolvedTeam = payloadTeam();
528
+ let team = resolvedTeam.team;
529
+ const teamPayloadMismatch = resolvedTeam.payload && resolvedTeam.env && resolvedTeam.payload !== resolvedTeam.env;
384
530
  if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
385
531
  if (!team) fatal("cannot determine target team (no config team block, no active team at root), so no identity could be minted — set a team: block in oats-config.yaml, or activate a team at the aweb root");
386
532
  // A bare team name (no namespace) resolves against the root's memberships.
@@ -437,7 +583,7 @@ if (event === "spawn") {
437
583
  run(["aw", "init", "--do-not-touch-agents-md"], home);
438
584
  const alias = joined.alias;
439
585
  const mismatch = joined.team_id !== team
440
- ? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : "";
586
+ ? ` [WARNING: joined ${joined.team_id}, expected ${team}]` : teamPayloadMismatch ? ` [WARNING: settings team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team]` : "";
441
587
  // Runtime integration: for Claude Code sessions the aweb-channel plugin
442
588
  // carries real-time push events. This hook does NOT install it. The plugin
443
589
  // is a DECLARED runtime requirement (oats.json), consented once at
@@ -458,11 +604,11 @@ if (event === "spawn") {
458
604
  ? ` Notification delivery: external (AWEB_DELIVERY=session): the host wake broker (aw wake) is registered for this home and nudges you when mail or chat arrives; the native aweb channel is not running. If you have waited long with nothing arriving, check \`aw mail inbox\` and \`aw chat pending\` yourself at task boundaries.`
459
605
  : "";
460
606
  out({
461
- meta: { team: joined.team_id, alias, delivery: deliveryMode },
607
+ meta: { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) },
462
608
  ...(env ? { env } : {}),
463
609
  brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
464
610
  ...(launch ? { launch } : {}),
465
- ...(mismatch ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : channelWarning ? { warning: channelWarning } : {}),
611
+ ...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : teamPayloadMismatch ? { warning: `oats-aweb: settings.oats.aweb.team ${resolvedTeam.payload} differs from OATS team ${resolvedTeam.env}; using payload team` } : channelWarning ? { warning: channelWarning } : {}),
466
612
  });
467
613
  } catch (e) {
468
614
  // A join may already have created a REMOTE identity before the failure.
@@ -480,6 +626,7 @@ if (event === "spawn") {
480
626
  if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
481
627
  out({ meta: { retired: true, retained: true, identityReleased: true, ...(meta.tookOverFrom ? { tookOverFrom: meta.tookOverFrom } : {}) }, warning: `oats-aweb: released the retained identity "${meta.alias}" (lock ${meta.lock || "?"} removed); the identity itself and ${meta.source || "its source"} are untouched${meta.tookOverFrom ? `; this seat had taken over from ${meta.tookOverFrom}` : ""}` });
482
628
  }
629
+ if (meta.identity?.mode === "global") globalGrantRetire(meta);
483
630
  // No alias means the spawn hook never reported an identity: nothing exists to
484
631
  // undo, which is completion. An alias WITH no local `.aw` is the opposite —
485
632
  // the remote record exists and its key is gone, so the self-delete cannot be
@@ -4,6 +4,13 @@ Your messaging layer is **aweb**. You have (or will be minted) a team-scoped
4
4
  aweb identity — alias = your instance name — on your deployment's team (see
5
5
  `instance.json` / your TASK.md briefing for the team).
6
6
 
7
+ Some instances serve a resident global identity through an expiring session
8
+ grant instead of holding their own root keys. In that mode, your TASK.md and
9
+ `instance.json` identify the resident alias, scopes, and expiry; root keys are
10
+ not in your home, and mint/revoke/join/identity-lifecycle commands are not yours
11
+ to run. If `aw mail` or `aw chat` says the grant expired or was revoked, stop
12
+ messaging and report the condition to your coordinator/human.
13
+
7
14
  **Load the skills at the right moments — do not work from memory:**
8
15
 
9
16
  - **Before your first `aw mail`/`aw chat` of a session**, load the
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "capability": "oats.aweb",
3
3
  "command": "aweb",
4
- "version": "1.11.2",
4
+ "version": "1.12.0",
5
5
  "compatibility": {
6
6
  "oats": ">=0.24.4"
7
7
  },
@@ -124,7 +124,8 @@
124
124
  "retire": "bin/oats-aweb.mjs retire"
125
125
  },
126
126
  "environment": [
127
- "AWEB_DELIVERY"
127
+ "AWEB_DELIVERY",
128
+ "AWEB_IDENTITY_HOME"
128
129
  ],
129
130
  "settings": {
130
131
  "delivery": {
@@ -134,6 +135,18 @@
134
135
  "session"
135
136
  ],
136
137
  "description": "channel: the native aweb channel packages wake the instance (default). session: delivery is external (AWEB_DELIVERY=session), no channel flag; the host wake broker registers the instance once it exists."
138
+ },
139
+ "team": {
140
+ "description": "Target aweb team id for identity lifecycle. In workspace v2 spawns this payload value wins over OATS_TEAM_ID/OATS_TEAM_NAME; if both are set and differ the hook warns and uses this setting."
141
+ },
142
+ "identity": {
143
+ "default": {
144
+ "mode": "local"
145
+ },
146
+ "description": "Identity selection. mode=local (default) mints an instance-local team identity or uses the existing identity.source retained-seat flow. mode=global acts as a named resident identity through a session grant; requires identity.resident and optional scopes/ttl."
147
+ },
148
+ "residents": {
149
+ "description": "Host-owned map for global mode: resident name to absolute custody directory whose .aw holds the resident root keys and team certificate. Put this only in oats-local.yaml settings.oats.aweb.residents; committed workspace or soul files must never carry custody paths."
137
150
  }
138
151
  },
139
152
  "environmentNamespaces": [
@@ -4,7 +4,7 @@ import { fs, join, dirname, resolve, readJSON, save, safePath, cliPath, oats, fa
4
4
  import { loadBindings, declaration, splitRef } from '../lib/config.mjs';
5
5
  import { register, registerCaptured, loadInvocationSourceReceipt, homeSource, loadSource, loadStatus, saveStatus, updateStatus, capture, scheduleSource, settleRetiredSchedule, service, markerPath, views } from '../lib/sources.mjs';
6
6
  import { runSource, complete, retry, readRun, requireQualifiedHelper } from '../lib/worker.mjs';
7
- import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource } from '../lib/migration.mjs';
7
+ import { initBase, migrate, deliverMigration, cutoverMigration, migrateSource, forgetMigration } from '../lib/migration.mjs';
8
8
  import { inspect } from '../lib/inspection.mjs';
9
9
  import { loadInvocationKnowledgeBinding } from '../lib/binding-wire.mjs';
10
10
  import { loadCapturedOkfInvocation, loadOkfSourceReceiptInput, assertOkfInvocationAction, requireOkfAdmittedAction, assertOkfSourceContext, assertOkfRegisteredSourceReplay } from '../lib/invocation-context.mjs';
@@ -20,6 +20,7 @@ oats okf init --base ALIAS --nodes FILE [--output PATH | --confirm] [--json]
20
20
  oats okf migrate --legacy PATH --base ALIAS --node NODE --output PATH [--json]
21
21
  oats okf migrate --deliver FILE | --cutover FILE --soul-dir PATH [--json]
22
22
  oats okf migrate --source-home PATH [--json]
23
+ oats okf migrate --forget ID [--json]
23
24
  oats okf unlock --lock PATH --token TOKEN [--json]
24
25
  Captured workers use oats operation run knowledge:harvest with SOURCE --deployment/--resolution/--home,
25
26
  --arg native-request=ABS_BACKEND_ONLY_JSON and optional --arg worker-mode=prepare|launch.
@@ -167,6 +168,7 @@ else {
167
168
  } else if(event==='init') result=initBase(loadBindings(),flags.base,flags.nodes,flags.output,{confirm:!!flags.confirm});
168
169
  else if(event==='migrate') {
169
170
  if(flags['source-home']) result=migrateSource(loadBindings(),flags['source-home']);
171
+ else if(flags.forget) result=forgetMigration(loadBindings(),flags.forget);
170
172
  else if(flags.deliver) result=deliverMigration(resolve(flags.deliver));
171
173
  else if(flags.cutover) result=cutoverMigration(resolve(flags.cutover),flags['soul-dir']);
172
174
  else result=migrate(loadBindings(),{legacy:flags.legacy,alias:flags.base,node:flags.node,output:flags.output});
@@ -7,12 +7,16 @@ function keys(value, allowed, label, code='E_CONFIG') {
7
7
  }
8
8
  export function settings() {
9
9
  const s = JSON.parse(process.env.OATS_SETTINGS || '{}');
10
- keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model'],'OATS_SETTINGS');
10
+ keys(s,['bindings-file','state-dir','harvest-runtime','harvest-model','git-timeout'],'OATS_SETTINGS');
11
+ if(s['git-timeout']!==undefined && (!Number.isInteger(s['git-timeout']) || s['git-timeout']<1)) fail('E_CONFIG','git-timeout must be a positive integer number of seconds');
11
12
  if(s['state-dir']!==undefined && (typeof s['state-dir']!=='string' || !isAbsolute(s['state-dir']) || resolve(s['state-dir'])!==s['state-dir'])) fail('E_CONFIG','state-dir must be a normalized absolute path');
12
13
  if(s['harvest-runtime']!==undefined && !['pi','claude','codex'].includes(s['harvest-runtime'])) fail('E_CONFIG','invalid harvest-runtime');
13
14
  if(s['harvest-model']!==undefined && (typeof s['harvest-model']!=='string' || !s['harvest-model'].trim())) fail('E_CONFIG','harvest-model must be a nonempty string');
14
15
  return s;
15
16
  }
17
+ /** Time budget for Git operations that talk to a remote (clone, fetch, push,
18
+ * ls-remote). Local object reads keep the short exec default. */
19
+ export function gitTimeoutMs() { return (settings()['git-timeout'] ?? 600)*1000; }
16
20
  export function noGit(path) {
17
21
  for (let p = safePath(path); ; p = dirname(p)) {
18
22
  if (fs.existsSync(join(p, '.git')) || (fs.existsSync(join(p, 'HEAD')) && fs.existsSync(join(p, 'objects')) && fs.existsSync(join(p, 'refs')))) fail('E_DIRECTORY_GIT', `directory store is in Git custody: ${p}; use kind git`);
@@ -70,7 +70,7 @@ export function unlock(path, token) {
70
70
  try { process.kill(o.pid, 0); fail('E_LOCKED', 'lock owner is still alive'); } catch (e) { if(e.code !== 'ESRCH') throw e; }
71
71
  fs.rmSync(path, { recursive: true }); syncDir(dirname(path)); return { unlocked: path };
72
72
  }
73
- export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
73
+ export const identityKeys = ['OATS_INSTANCE','OATS_INSTANCE_HOME','OATS_HOME','PI_AGENT_HOME','PI_AGENT_NAME','PI_AGENT_INSTANCE','PI_AGENTS_ROOT','OATS_ROOT','OATS_SOUL','OATS_SOUL_ID','OATS_AGENT','OATS_KIND','OATS_EVENT','OATS_CONTEXT','OATS_REPO','OATS_WORK','OATS_BRANCH','OATS_META','OATS_SETTINGS','OATS_BINDING_FILE','OATS_SOURCE_RECEIPT_FILE','OATS_INVOCATION_CONTEXT_FILE','OATS_DEPLOYMENT','OATS_RESOLUTION'];
74
74
  export function cleanEnv(env = process.env) {
75
75
  return Object.fromEntries(Object.entries(env).filter(([k]) => !/^(OATS_(?!HOME_DIR$|PACKAGE_CATALOG$)|PI_AGENT|GIT_)/.test(k)));
76
76
  }
@@ -30,10 +30,16 @@ export function migrate(bindings,{legacy,alias,node,output}) {
30
30
  if(paths.some(path=>overlaps(path,output))) fail('E_PATH','migration stage overlaps configured accepted base or coordination artifacts');
31
31
  }
32
32
  const original=tree(legacy);
33
+ // Stage the accepted base before any record exists: a base that cannot be
34
+ // read leaves nothing behind. From the record on, a failure is recorded in
35
+ // it rather than left as an unexplained directory.
36
+ const stage=stageBase(base,output);
33
37
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
34
38
  const id=randomUUID();const dir=join(bindings.stateDir,'migrations',id);fs.mkdirSync(dir,{recursive:true,mode:0o700});
35
39
  save(join(dir,'legacy.json'),original); // byte-preserving backup BEFORE any delivery
36
- const stage=stageBase(base,output); const spec=stage.meta.nodes[node];if(!spec) fail('E_OWNER','migration node must be explicitly provisioned first');
40
+ const recordFile=join(dir,'migration.json');
41
+ try {
42
+ const spec=stage.meta.nodes[node];if(!spec) fail('E_OWNER','migration node must be explicitly provisioned first');
37
43
  const prefix=spec.path+'/';
38
44
  if(Object.keys(stage.files).some(p=>p.startsWith(prefix) && ![prefix+'index.md',prefix+'log.md'].includes(p))) fail('E_MIGRATION','target node is not empty; merge migration requires human judgment');
39
45
  const after={...stage.files};for(const p of Object.keys(after)) if(p.startsWith(prefix)) delete after[p];
@@ -48,8 +54,23 @@ export function migrate(bindings,{legacy,alias,node,output}) {
48
54
  // Replace only the empty pre-provisioned node, not unrelated accepted bytes.
49
55
  fs.rmSync(join(stage.root,spec.path),{recursive:true});materialize(stage.root,after);validateBase(stage.root,base);
50
56
  const proposalFile=join(dir,'proposal.json');const proposal={version:1,run:id,created:new Date().toISOString(),file:proposalFile,before:stage.files,after};save(proposalFile,proposal);
51
- const record={version:1,id,alias,node,base,owner:spec.owner,legacy,originalDigest:digest(original),stage:{root:stage.root,checkout:stage.checkout,head:stage.head},proposal:proposalFile,proposalHash:hash(proposal),receipt:{status:'staged'}};save(join(dir,'migration.json'),record);
52
- return {status:'staged',migration:join(dir,'migration.json'),originalPreserved:true,stage:stage.root};
57
+ const record={version:1,id,alias,node,base,owner:spec.owner,legacy,originalDigest:digest(original),stage:{root:stage.root,checkout:stage.checkout,head:stage.head},proposal:proposalFile,proposalHash:hash(proposal),receipt:{status:'staged'}};save(recordFile,record);
58
+ return {status:'staged',migration:recordFile,originalPreserved:true,stage:stage.root};
59
+ } catch(e) {
60
+ save(recordFile,{version:1,id,alias,node,base,legacy,originalDigest:digest(original),receipt:{status:'failed',error:e.message,code:e.code||'E_MIGRATION',at:new Date().toISOString()}});
61
+ throw e;
62
+ }
63
+ }
64
+ /** Remove a migration record that never delivered anything (staged or failed).
65
+ * The backup it holds is the legacy bundle, which still exists at its source;
66
+ * a delivered record is custody and stays. */
67
+ export function forgetMigration(bindings,id) {
68
+ identifier(id);const dir=join(bindings.stateDir,'migrations',id);const file=join(dir,'migration.json');
69
+ if(!fs.existsSync(dir)) fail('E_MIGRATION',`unknown migration ${id}`);
70
+ const status=fs.existsSync(file)?readJSON(file).receipt?.status:'incomplete';
71
+ if(!['failed','staged','incomplete'].includes(status)) fail('E_MIGRATION',`migration ${id} is ${status}: a delivered migration is custody and cannot be forgotten`);
72
+ fs.rmSync(dir,{recursive:true,force:true});
73
+ return {status:'forgotten',id,was:status};
53
74
  }
54
75
  export function deliverMigration(file) {
55
76
  safePath(file);const m=readJSON(file);const proposal=readJSON(m.proposal);if(hash(proposal)!==m.proposalHash) fail('E_MIGRATION','migration proposal changed');
@@ -195,6 +195,7 @@ export function register(home) {
195
195
  const soul=fs.realpathSync(process.env.OATS_SOUL || join(home,'soul'));
196
196
  const work=fs.existsSync(join(home,'work'))?fs.realpathSync(join(home,'work')):join(home,'work');
197
197
  const decl=declaration(soul);
198
+ const soulId=process.env.OATS_SOUL_ID || null;
198
199
  const bindings=loadBindings(undefined,{sourceHome:home,sourceWork:work});
199
200
  const context=fs.realpathSync(process.env.OATS_CONTEXT || meta.repo || fail('E_CONFIG','source requires durable config context'));
200
201
  if(overlaps(home,context) && context.startsWith(home)) fail('E_PATH','config context cannot be in disposable home');
@@ -203,11 +204,7 @@ export function register(home) {
203
204
  if(!agent || !instance) fail('E_SOURCE','source instance/agent required');
204
205
  fs.mkdirSync(bindings.stateDir,{recursive:true,mode:0o700});
205
206
  const ownersFile=join(bindings.stateDir,'owners.json');
206
- withLock(join(bindings.stateDir,'owners.lock'),()=>{
207
- const owners=fs.existsSync(ownersFile)?readJSON(ownersFile):{};
208
- if(Object.hasOwn(owners,decl.owner) && owners[decl.owner]!==soul) fail('E_OWNER','stable owner ID already identifies a different soul in this state namespace');
209
- owners[decl.owner]=soul;save(ownersFile,owners);
210
- });
207
+ pinOwner(ownersFile,decl.owner,{id:soulId,soulName:agent,path:soul});
211
208
  const id=randomUUID(); const dir=join(bindings.stateDir,'sources',id);
212
209
  // Copy only the role document, never instance.json wholesale, launch recipes,
213
210
  // environment, credentials, source worktree, or third-party message stores.
@@ -235,6 +232,24 @@ export function register(home) {
235
232
  }
236
233
  return finishRegistration({...source,file});
237
234
  }
235
+ /** Pin a stable owner id to the soul it identifies. The kernel names a soul by
236
+ * identity (OATS_SOUL_ID: repository key plus soul name) so the pin survives
237
+ * the per-commit soul copies a workspace deployment materializes; a classic
238
+ * soul keeps the resolved path. A row written by an earlier version as a path
239
+ * under agents/<same soul name>/(soul|souls/<commit>) is rewritten to the
240
+ * identity once; any other mismatch is a different soul and is refused. */
241
+ export function pinOwner(ownersFile,owner,{id,soulName,path}) {
242
+ const value=id || path;
243
+ return withLock(join(dirname(ownersFile),'owners.lock'),()=>{
244
+ const owners=fs.existsSync(ownersFile)?readJSON(ownersFile):{};
245
+ if(!obj(owners)) fail('E_OWNER','invalid owner registry');
246
+ const prior=Object.hasOwn(owners,owner)?owners[owner]:undefined;
247
+ const samePath=typeof prior==='string' && new RegExp(`/agents/${soulName.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')}/(soul|souls/[^/]+)$`).test(prior);
248
+ if(prior!==undefined && prior!==value && !(id && samePath)) fail('E_OWNER','stable owner ID already identifies a different soul in this state namespace');
249
+ if(prior!==value) {owners[owner]=value;save(ownersFile,owners);}
250
+ return value;
251
+ });
252
+ }
238
253
  function enqueue(source,status,payload) {
239
254
  const id=hash(payload);const path=join(dirname(source.file),'inputs',`${id}.json`);
240
255
  if(!fs.existsSync(path)) save(path,payload);
@@ -246,19 +261,35 @@ export function input(source,id) {
246
261
  const value=readJSON(join(dirname(source.file),'inputs',`${id}.json`));
247
262
  if(hash(value)!==id) fail('E_INPUT','durable evidence hash mismatch');return value;
248
263
  }
249
- /** Switch a retired source's job off once nothing is pending. Idempotent; a
250
- * scheduler failure is recorded, never thrown — the evidence is already safe. */
264
+ /** Take a drained, retired source's job out of the kernel scheduler. The job is
265
+ * first switched off, then removed: the definition it carried is kept as
266
+ * evidence in this source's own schedule.json, so nothing about the run is
267
+ * lost, and `oats schedule list` stops accumulating dead okf-<id> rows. A job
268
+ * that is still running (or has unresolved effects) stays disabled and is
269
+ * removed on the worker's next settle. Idempotent; a scheduler failure is
270
+ * recorded, never thrown — the evidence is already safe. */
251
271
  export function settleRetiredSchedule(source) {
252
272
  const status=loadStatus(source);
253
- if(status.schedule?.settled===true) return {status:'already-disabled',id:status.schedule.id};
273
+ if(status.schedule?.removed===true) return {status:'already-removed',id:status.schedule.id};
254
274
  if(!status.retired || status.auto || status.schedule?.id===undefined) return {status:'kept'};
275
+ const id=status.schedule.id;
276
+ const mark=(patch)=>updateStatus(source,current=>{current.schedule={...current.schedule,...patch};});
277
+ try {
278
+ if(status.schedule.settled!==true) {
279
+ oats(['schedule','disable',id,'--dir',source.context,'--json'],source.context);
280
+ mark({settled:true,settledAt:new Date().toISOString(),settleError:undefined});
281
+ }
282
+ } catch(e) {
283
+ if(e.code!=='E_SCHEDULE_UNKNOWN') {mark({settled:false,settleError:e.message});return {status:'disable-failed',id};}
284
+ }
255
285
  try {
256
- oats(['schedule','disable',status.schedule.id,'--dir',source.context,'--json'],source.context);
257
- updateStatus(source,current=>{current.schedule={...current.schedule,settled:true,settledAt:new Date().toISOString()};});
258
- return {status:'disabled',id:status.schedule.id};
286
+ oats(['schedule','remove',id,'--dir',source.context,'--json'],source.context);
287
+ mark({removed:true,removedAt:new Date().toISOString(),removeError:undefined});
288
+ return {status:'removed',id};
259
289
  } catch(e) {
260
- updateStatus(source,current=>{current.schedule={...current.schedule,settled:false,settleError:e.message};});
261
- return {status:'disable-failed',id:status.schedule.id};
290
+ if(e.code==='E_SCHEDULE_UNKNOWN') {mark({removed:true,removedAt:new Date().toISOString(),removeError:undefined});return {status:'already-removed',id};}
291
+ mark({removed:false,removeError:e.message});
292
+ return {status:e.code==='E_SCHEDULE_RUNNING'?'disabled-pending-removal':'remove-failed',id};
262
293
  }
263
294
  }
264
295
  export function capture(source,{final=false,deadlineMs=85000}={}) {
@@ -336,8 +367,9 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
336
367
  if(final) {
337
368
  status.retired=true;status.retiredAt=new Date().toISOString();
338
369
  // A retired source whose every captured input is already processed has
339
- // no further work: its schedule is switched off now (never deleted —
340
- // the job definition stays as evidence). Anything still pending keeps
370
+ // no further work: its schedule is switched off and removed from the
371
+ // kernel scheduler (settleRetiredSchedule); the definition stays as
372
+ // evidence in this source's schedule.json. Anything still pending keeps
341
373
  // the job enabled until the worker drains it (see worker.mjs).
342
374
  const drained=status.captured.inputs.every(id=>status.processed.includes(id));
343
375
  status.auto=status.auto && !noLaunch && !drained;
@@ -349,10 +381,14 @@ export function capture(source,{final=false,deadlineMs=85000}={}) {
349
381
  });
350
382
  }
351
383
  export function scheduleSource(source) {
384
+ // A retired, drained source whose job was already taken out of the scheduler
385
+ // has nothing left to run: do not recreate the job (retire is re-entrant).
386
+ const current=loadStatus(source);
387
+ if(current.retired && current.schedule?.removed===true) return current.schedule.result;
352
388
  const captured=source.registration?.schemaVersion===1 && source.registration.kind==='captured';
353
389
  const argv=captured?['oats','okf','run-source','--source',source.file,'--deployment',source.executionBinding.deployment,'--resolution',source.executionBinding.resolution.id,'--json']
354
390
  :['oats','okf','run-source','--source',source.file,'--soul',source.agent,'--json'];
355
- const spec={id:`okf-${source.id}`,kind:'command',enabled:loadStatus(source).auto,cron:source.bindings.cron,tz:source.bindings.tz,cwd:source.context,argv,
391
+ const spec={id:`okf-${source.id}`,kind:'command',enabled:current.auto,cron:source.bindings.cron,tz:source.bindings.tz,cwd:source.context,argv,
356
392
  ...(captured?{definitionVersion:2,recurrencePolicy:'capture',responsibleHuman:source.responsibleHuman}: {})};
357
393
  const file=join(dirname(source.file),'schedule.json');
358
394
  try {