@awebai/oats 0.25.8 → 0.26.0

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.
Files changed (185) hide show
  1. package/README.md +8 -6
  2. package/bin/oats.mjs +576 -1714
  3. package/capabilities/oats-authoring/oats-package.json +2 -2
  4. package/capabilities/oats-authoring/oats.json +2 -2
  5. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +46 -25
  6. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +13 -6
  7. package/capabilities/oats-aweb/bin/oats-aweb.mjs +334 -72
  8. package/capabilities/oats-aweb/injects/aweb.md +7 -2
  9. package/capabilities/oats-aweb/lib/binding-wire.mjs +107 -5
  10. package/capabilities/oats-aweb/lib/captured-native.mjs +1 -1
  11. package/capabilities/oats-aweb/lib/grant-custody.mjs +38 -0
  12. package/capabilities/oats-aweb/oats.json +14 -4
  13. package/capabilities/oats-jira/bin/oats-jira.mjs +4 -4
  14. package/capabilities/oats-jira/oats.json +2 -2
  15. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +6 -3
  16. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +6 -4
  17. package/capabilities/oats-linear/oats.json +2 -2
  18. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +6 -0
  19. package/capabilities/oats-okf/bin/oats-okf.mjs +1 -1
  20. package/capabilities/oats-okf/lib/binding-wire.mjs +1 -1
  21. package/capabilities/oats-okf/lib/migration.mjs +2 -2
  22. package/capabilities/oats-okf/lib/sources.mjs +5 -4
  23. package/capabilities/oats-okf/lib/stores.mjs +40 -9
  24. package/capabilities/oats-okf/lib/worker.mjs +3 -3
  25. package/capabilities/oats-okf/oats.json +1 -1
  26. package/capabilities/oats-review/oats.json +3 -2
  27. package/docs/capabilities.md +218 -47
  28. package/docs/capability-manifest.schema.json +13 -4
  29. package/docs/configuration.md +17 -5
  30. package/docs/conventions.md +16 -26
  31. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +1 -1
  32. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +3 -3
  33. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +2 -2
  34. package/docs/design/2026-09-15-portable-souls-handoff.md +2 -2
  35. package/docs/design/2026-09-15-portable-souls-implementation.md +1 -1
  36. package/docs/design/2026-09-20-redesign-program-board.md +2 -2
  37. package/docs/design/2026-09-23-workspace-module-contracts.md +1 -1
  38. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +1 -1
  39. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +54 -10
  40. package/docs/design/2026-09-24-phase-d-plan.md +77 -0
  41. package/docs/design/2026-09-25-teams-contract.md +226 -0
  42. package/docs/design/README.md +3 -3
  43. package/docs/design/launch-configurations.md +20 -16
  44. package/docs/design/operations-contract.md +27 -10
  45. package/docs/desktop-cli-api.md +546 -264
  46. package/docs/desktop-instance-start.md +1 -1
  47. package/docs/desktop.md +7 -13
  48. package/docs/execution-targets.md +16 -18
  49. package/docs/first-team.md +14 -17
  50. package/docs/implementation.md +28 -59
  51. package/docs/integrations.md +88 -32
  52. package/docs/knowledge-capability-authoring.md +1 -1
  53. package/docs/knowledge-reference/package-craft.md +10 -8
  54. package/docs/knowledge-theory.md +1 -1
  55. package/docs/knowledge.md +10 -11
  56. package/docs/layers.md +16 -17
  57. package/docs/oats-local.schema.json +29 -1
  58. package/docs/oats-membership.schema.json +5 -3
  59. package/docs/oats-package.schema.json +2 -2
  60. package/docs/oats-workspace.schema.json +1 -1
  61. package/docs/{official-marketplace.md → official-catalog.md} +15 -16
  62. package/docs/packages.md +75 -52
  63. package/docs/release-notes/v0.22.0.md +1 -1
  64. package/docs/release-notes/v0.23.1.md +1 -1
  65. package/docs/release-notes/v0.25.9.md +23 -0
  66. package/docs/release-notes/v0.26.0.md +670 -0
  67. package/docs/schedules.md +48 -126
  68. package/docs/soul.schema.json +11 -4
  69. package/docs/souls-and-instances.md +56 -43
  70. package/docs/workspaces.md +80 -58
  71. package/injects/instance-boundary.md +1 -1
  72. package/injects/work-attached.md +1 -1
  73. package/injects/work-workspace.md +2 -2
  74. package/lib/{portable-files.mjs → bounded-read.mjs} +6 -6
  75. package/lib/{portable-values.mjs → canonical-json.mjs} +3 -12
  76. package/lib/capability-contract.mjs +110 -0
  77. package/lib/config-data.mjs +2 -2
  78. package/lib/core.mjs +700 -4824
  79. package/lib/digest.mjs +12 -0
  80. package/lib/instance-inspect.mjs +396 -0
  81. package/lib/instance-lifecycle.mjs +3 -4
  82. package/lib/instance-resolution.mjs +212 -26
  83. package/lib/instruction-composition.mjs +0 -20
  84. package/lib/materialize.mjs +6 -4
  85. package/lib/operator-dispatch.mjs +33 -13
  86. package/lib/packages.mjs +25 -190
  87. package/lib/provider-binding.mjs +4 -2
  88. package/lib/provider-reasons.mjs +3 -68
  89. package/lib/resolve.mjs +204 -68
  90. package/lib/schedule.mjs +97 -272
  91. package/lib/servers.mjs +13 -13
  92. package/lib/{portable-shape.mjs → shape.mjs} +4 -3
  93. package/lib/tree-copy.mjs +44 -0
  94. package/lib/workspace.mjs +125 -20
  95. package/package-catalog.json +7 -7
  96. package/package.json +1 -1
  97. package/skills/integration-authoring/SKILL.md +48 -40
  98. package/skills/oats-getting-started/SKILL.md +105 -110
  99. package/skills/oats-support/SKILL.md +2 -2
  100. package/skills/soul-craft/SKILL.md +13 -6
  101. package/bin/oats-pi-sdk-host.mjs +0 -17
  102. package/docs/2026-09-03-architecture-proposal.md +0 -642
  103. package/docs/artifact-approvals.schema.json +0 -7
  104. package/docs/captured-invocation-context.schema.json +0 -7
  105. package/docs/captured-resolution.schema.json +0 -7
  106. package/docs/design/package-engine-contract.md +0 -813
  107. package/docs/design/package-runtime-api.md +0 -588
  108. package/docs/desktop-succession.md +0 -57
  109. package/docs/execution-capsule.schema.json +0 -108
  110. package/docs/first-team-demo.md +0 -92
  111. package/docs/knowledge-migration.md +0 -147
  112. package/docs/migration-from-oas.md +0 -103
  113. package/docs/oats-config.schema.json +0 -172
  114. package/docs/oats-lock-v3.schema.json +0 -7
  115. package/docs/oats-lock.schema.json +0 -175
  116. package/docs/operating-team-migration.md +0 -470
  117. package/docs/portable.schema.json +0 -2512
  118. package/docs/provider-check-input.schema.json +0 -7
  119. package/docs/rebuild-to-v2.md +0 -511
  120. package/docs/workspace-adoption.md +0 -74
  121. package/injects/framework-workspace.md +0 -7
  122. package/injects/local-soul.md +0 -19
  123. package/injects/oats-portable.md +0 -20
  124. package/injects/oats.md +0 -11
  125. package/injects/portable-instance-boundary.md +0 -39
  126. package/injects/portable-work-directory.md +0 -29
  127. package/lib/artifact-approvals.mjs +0 -120
  128. package/lib/artifact-tree.mjs +0 -141
  129. package/lib/capability-artifacts.mjs +0 -179
  130. package/lib/capability-execution.mjs +0 -15
  131. package/lib/capability-inputs.mjs +0 -39
  132. package/lib/capability-provenance.mjs +0 -231
  133. package/lib/captured-action-shape.mjs +0 -21
  134. package/lib/captured-admission-shape.mjs +0 -20
  135. package/lib/captured-binding-file.mjs +0 -36
  136. package/lib/captured-dispatch.mjs +0 -66
  137. package/lib/captured-instance-index.mjs +0 -277
  138. package/lib/captured-invocation-context.mjs +0 -130
  139. package/lib/captured-launch-request.mjs +0 -66
  140. package/lib/captured-operation-process.mjs +0 -15
  141. package/lib/captured-pi-custody.mjs +0 -29
  142. package/lib/captured-pi-host.mjs +0 -167
  143. package/lib/captured-pi-outcome.mjs +0 -172
  144. package/lib/captured-resolutions.mjs +0 -275
  145. package/lib/captured-scaffold.mjs +0 -87
  146. package/lib/captured-selector.mjs +0 -28
  147. package/lib/captured-session-backend.mjs +0 -52
  148. package/lib/captured-source-receipt-file.mjs +0 -72
  149. package/lib/helper-injection-policy.mjs +0 -104
  150. package/lib/legacy-lock-codec.mjs +0 -106
  151. package/lib/manifest-settings.mjs +0 -84
  152. package/lib/package-closure.mjs +0 -48
  153. package/lib/package-materialization.mjs +0 -83
  154. package/lib/pi-sdk-host.mjs +0 -229
  155. package/lib/portable-artifacts.mjs +0 -115
  156. package/lib/portable-choices.mjs +0 -82
  157. package/lib/portable-composition.mjs +0 -136
  158. package/lib/portable-digest.mjs +0 -105
  159. package/lib/portable-identity.mjs +0 -40
  160. package/lib/portable-lock.mjs +0 -117
  161. package/lib/portable-onboarding-request.mjs +0 -49
  162. package/lib/portable-onboarding.mjs +0 -256
  163. package/lib/portable-package-preparation.mjs +0 -188
  164. package/lib/portable-policy.mjs +0 -44
  165. package/lib/portable-soul.mjs +0 -42
  166. package/lib/portable-state.mjs +0 -80
  167. package/lib/prepare-composition.mjs +0 -170
  168. package/lib/prepared-bindings.mjs +0 -92
  169. package/lib/prepared-resources.mjs +0 -127
  170. package/lib/provider-binding-broker.mjs +0 -65
  171. package/lib/provider-binding-wire.mjs +0 -116
  172. package/lib/readiness.mjs +0 -225
  173. package/lib/repository-observation.mjs +0 -226
  174. package/lib/resolution-shape.mjs +0 -393
  175. package/lib/schedule-capsule.mjs +0 -206
  176. package/lib/soul-constraints.mjs +0 -40
  177. package/lib/source-projection.mjs +0 -84
  178. package/lib/source-spec.mjs +0 -189
  179. package/lib/workspace-definition.mjs +0 -126
  180. package/lib/workspace-discovery.mjs +0 -146
  181. package/skills/oats/SKILL.md +0 -162
  182. package/skills/oats-config/SKILL.md +0 -164
  183. package/skills/oats-packages/SKILL.md +0 -184
  184. package/skills/oats-portable/SKILL.md +0 -115
  185. package/skills/oats-portable-artifacts/SKILL.md +0 -63
@@ -7,8 +7,8 @@
7
7
  * oats-aweb retire gracefully self-delete it (BEFORE the home dir is removed)
8
8
  * oats-aweb roster list the aweb team's members — the cross-machine directory
9
9
  * of live instances (alias = instance name) and humans
10
- * oats-aweb setup guided onboarding: check the aw CLI, initialize the team
11
- * scope's aweb workspace, create/join the team
10
+ * oats-aweb setup guided onboarding: check the aw CLI, initialize the
11
+ * messaging root with aw init / aw team join, verify team
12
12
  *
13
13
  * Env contract (set by the kernel):
14
14
  * OATS_EVENT spawn|retire
@@ -43,7 +43,8 @@ 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";
46
- import { parseBindingJson } from "../lib/binding-wire.mjs";
46
+ import { CUSTODY_ATTACH_MIN, grantYamlCustodySocket, parseBindingJson } from "../lib/binding-wire.mjs";
47
+ import { custodyPreflight } from "../lib/grant-custody.mjs";
47
48
 
48
49
  /** Run a command as ARGV — never a shell string. Team ids, aliases, instance
49
50
  * names and invite tokens all flow through here; quoting them correctly is a
@@ -93,14 +94,20 @@ const parseSecretJson = (text, what) => {
93
94
  * would read as "aw is missing" on every such host. */
94
95
  /** The installed aw's version from `aw version` ("aw 1.36.1 ..."), or
95
96
  * undefined when it cannot be read; compared as numeric triples. */
97
+ function awVersionTriple() {
98
+ try { return /aw\s+v?(\d+)\.(\d+)\.(\d+)/.exec(run(["aw", "version"], undefined, 10000)); } catch { return undefined; }
99
+ }
96
100
  function awAtLeast(floor) {
97
- let v;
98
- try { v = /aw\s+v?(\d+)\.(\d+)\.(\d+)/.exec(run(["aw", "version"], undefined, 10000)); } catch { return false; }
101
+ const v = awVersionTriple();
99
102
  if (!v) return false;
100
103
  const a = v.slice(1, 4).map(Number), b = floor.split(".").map(Number);
101
104
  for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] > b[i]; }
102
105
  return true;
103
106
  }
107
+ function awVersionLabel() {
108
+ const v = awVersionTriple();
109
+ return v ? v.slice(1, 4).join(".") : "unknown";
110
+ }
104
111
 
105
112
  function onPath(cmd) {
106
113
  for (const dir of String(process.env.PATH || "").split(delimiter)) {
@@ -137,7 +144,7 @@ try {
137
144
  const manifest = JSON.parse(readFileSync(new URL("../oats.json", import.meta.url), "utf8"));
138
145
  const selected = requireCapturedAwebAction(loaded, event, manifest);
139
146
  const settings = parseBindingJson(Buffer.from(process.env.OATS_SETTINGS || "{}"));
140
- if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => k !== "delivery")) throw new Error("captured settings support delivery only; no identity copying or ambient fallback");
147
+ if (!settings || typeof settings !== "object" || Array.isArray(settings) || Object.keys(settings).some(k => !["delivery", "team", "root", "roots"].includes(k))) throw new Error("captured settings support delivery/team/root readiness only; no identity copying or ambient fallback");
141
148
  const checked = assessCapturedSessionReadiness({ binding: selected.binding, invocation: selected.context, settings }, {
142
149
  query(args, options) { selected.assertCurrent(); const result = querySelectedKernel(args, options); selected.assertCurrent(); return result; },
143
150
  });
@@ -152,6 +159,9 @@ try {
152
159
  const event = process.env.OATS_EVENT || process.argv[2];
153
160
  const instance = process.env.OATS_INSTANCE;
154
161
  const home = process.env.OATS_HOME || process.cwd();
162
+ const AWEB_ALIAS_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
163
+ const AWEB_ALIAS_RULE = "invalid alias: aweb aliases must be 1-64 characters, start with a letter or digit, and then contain only letters, digits, '-' or '_'";
164
+ const ALIAS_REUSE_REMEDY = "spawn with a different --name (kernels 0.26.0+) or a different --purpose";
155
165
  // Effective capability settings, injected by kernel dispatch (OATS_SETTINGS).
156
166
  // delivery: "channel" (default) keeps the native channel packages waking the
157
167
  // instance; "session" hands delivery to the host wake broker (aweb-abil):
@@ -166,6 +176,7 @@ const deliveryMode = (() => {
166
176
  const identitySettings = settings.identity && typeof settings.identity === "object" && !Array.isArray(settings.identity) ? settings.identity : {};
167
177
  const identityMode = identitySettings.mode === undefined || identitySettings.mode === null || identitySettings.mode === "" ? "local" : String(identitySettings.mode);
168
178
  if (!["local", "global"].includes(identityMode) && ["spawn", "retire"].includes(event)) fatal(`identity.mode must be either "local" or "global" (got ${JSON.stringify(identitySettings.mode)})`);
179
+ if (event === "spawn" && identityMode === "local" && !identitySettings.source && (!instance || !AWEB_ALIAS_RE.test(instance))) fatal(`${AWEB_ALIAS_RULE}; OATS_INSTANCE is ${instance ? "not valid" : "missing"}, so no identity could be minted`);
169
180
  const payloadTeam = () => {
170
181
  const fromSettings = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : undefined;
171
182
  const fromEnv = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || undefined;
@@ -194,7 +205,7 @@ function gitRootOf(startDir) {
194
205
  d = parent;
195
206
  }
196
207
  }
197
- function awebRoot() {
208
+ function classicAwebRoot() {
198
209
  const candidates = [];
199
210
  const push = (p) => { if (p && !candidates.includes(resolve(p))) candidates.push(resolve(p)); };
200
211
  push(process.env.OATS_TEAM_SCOPE);
@@ -206,6 +217,36 @@ function awebRoot() {
206
217
  for (const c of candidates) if (existsSync(join(c, ".aw"))) return c;
207
218
  return undefined;
208
219
  }
220
+ const hasWorkspaceV2Facts = () => !!(process.env.OATS_WORKSPACE_KEY || process.env.OATS_WORKSPACE_NAME || process.env.OATS_TEAM_LABEL);
221
+ const isClassicDeployment = () => !!process.env.OATS_TEAM_SCOPE && !hasWorkspaceV2Facts();
222
+ const teamConfigRemedy = () => `set messaging.byTeam.<label>.team in the workspace file or settings.oats.aweb.team${isClassicDeployment() ? " (classic: set team.id in oats-config.yaml)" : ""}`;
223
+ function declaredRootCandidate(team = payloadTeam().team) {
224
+ const roots = settings.roots && typeof settings.roots === "object" && !Array.isArray(settings.roots) ? settings.roots : {};
225
+ if (team && typeof roots[team] === "string" && roots[team].trim()) return { root: roots[team].trim(), key: `settings.oats.aweb.roots[${JSON.stringify(team)}]`, declared: true };
226
+ if (typeof settings.root === "string" && settings.root.trim()) return { root: settings.root.trim(), key: "settings.oats.aweb.root", declared: true };
227
+ return undefined;
228
+ }
229
+ function rootSettingCandidate(team = payloadTeam().team) {
230
+ const declared = declaredRootCandidate(team);
231
+ if (declared) return declared;
232
+ const fallback = process.env.OATS_WORKSPACE || (!isClassicDeployment() ? process.env.OATS_TEAM_SCOPE : undefined) || process.cwd();
233
+ return fallback ? { root: fallback, key: "settings.oats.aweb.root", declared: false } : undefined;
234
+ }
235
+ function awebRootProblem(candidate) {
236
+ if (!candidate?.root) return `no messaging root at ${process.cwd()}: run oats aweb setup there or set settings.oats.aweb.root`;
237
+ if (!isAbsolute(candidate.root)) return `${candidate.key} must be an absolute directory whose .aw is the aweb minting root`;
238
+ return `no messaging root at ${resolve(candidate.root)}: run oats aweb setup there or set ${candidate.key}`;
239
+ }
240
+ function resolveAwebRoot() {
241
+ const declared = declaredRootCandidate();
242
+ if (declared && isAbsolute(declared.root) && existsSync(join(resolve(declared.root), ".aw"))) return resolve(declared.root);
243
+ if (declared?.declared) return undefined;
244
+ if (isClassicDeployment()) return classicAwebRoot();
245
+ const candidate = rootSettingCandidate();
246
+ if (candidate && isAbsolute(candidate.root) && existsSync(join(resolve(candidate.root), ".aw"))) return resolve(candidate.root);
247
+ return undefined;
248
+ }
249
+ function awebRoot() { return resolveAwebRoot(); }
209
250
 
210
251
  /** Team memberships from `aw team list --json`. The current CLI returns
211
252
  * `memberships`; older output used `teams`. Spawn resolution and `oats aweb
@@ -253,8 +294,23 @@ function wakeRegister(instanceHome, identityHome) {
253
294
  function wakeDeregister(instanceHome) {
254
295
  try { run(["aw", "wake", "deregister", "--home", instanceHome], instanceHome, 60000); return true; } catch { return false; }
255
296
  }
256
- const DEFAULT_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send"];
297
+ // The first aw release whose grant subtree accepts `aw id grant mint --team
298
+ // <team-id>` (and the full normal/reviewer scope lists): aw 1.36.2, paired with
299
+ // aweb server 1.27.5 for complete custody operations. aw 1.36.1 has neither.
300
+ const GRANT_TEAM_FLAG_MIN = "1.36.2";
301
+ const NORMAL_GRANT_SCOPES = ["mail.read", "mail.send", "chat.read", "chat.send", "events.read", "coord.read", "coord.write", "presence.write", "contacts.read", "contacts.write"];
302
+ const REVIEWER_GRANT_SCOPES = ["mail.read", "chat.read", "events.read", "coord.read", "presence.write"];
257
303
  const residentKeyHint = (name) => `oats-local.yaml settings.oats.aweb.residents.${name || "<name>"}`;
304
+ const grantProfile = () => identitySettings.profile === undefined || identitySettings.profile === null || identitySettings.profile === "" ? "normal" : String(identitySettings.profile);
305
+ function grantScopes() {
306
+ if (Array.isArray(identitySettings.scopes) && identitySettings.scopes.length) return identitySettings.scopes.map(String);
307
+ const profile = grantProfile();
308
+ if (profile === "normal") return [...NORMAL_GRANT_SCOPES];
309
+ if (profile === "reviewer") return [...REVIEWER_GRANT_SCOPES];
310
+ fatal(`identity.profile must be one of "normal" or "reviewer" (got ${JSON.stringify(identitySettings.profile)})`);
311
+ }
312
+ const grantE2eeRequired = () => identitySettings.e2ee !== false;
313
+ const grantRenewMode = () => identitySettings.renew === undefined || identitySettings.renew === null || identitySettings.renew === "" ? "off" : String(identitySettings.renew);
258
314
  function resolveResidentCustody(name) {
259
315
  if (!name) fatal(`identity.mode "global" requires identity.resident; set ${residentKeyHint("<name>")} to the absolute custody directory for that resident identity`);
260
316
  const residents = settings.residents && typeof settings.residents === "object" && !Array.isArray(settings.residents) ? settings.residents : {};
@@ -284,6 +340,21 @@ function revokeGrant(custody, grantId) {
284
340
  throw e;
285
341
  }
286
342
  }
343
+ function grantYamlNestedScalar(text, sectionName, key) {
344
+ const lines = String(text ?? "").split(/\r?\n/);
345
+ let inSection = false, baseIndent = 0;
346
+ for (const line of lines) {
347
+ const section = new RegExp(`^(\\s*)${sectionName}:\\s*(?:#.*)?$`).exec(line);
348
+ if (section) { inSection = true; baseIndent = section[1].length; continue; }
349
+ if (inSection) {
350
+ const ind = /^(\s*)/.exec(line)?.[1].length || 0;
351
+ if (line.trim() && ind <= baseIndent) inSection = false;
352
+ const value = new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`).exec(line);
353
+ if (value) return value[1].trim();
354
+ }
355
+ }
356
+ return undefined;
357
+ }
287
358
  function recoverGrantHome(grantHome) {
288
359
  try {
289
360
  const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
@@ -291,9 +362,120 @@ function recoverGrantHome(grantHome) {
291
362
  const m = text.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'\\n#]+)["']?\\s*$`, "m"));
292
363
  return m ? m[1].trim() : undefined;
293
364
  };
294
- return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at") };
365
+ return { grantId: scalar("grant_id"), team: scalar("team_id"), expiresAt: scalar("expires_at"), subjectAlias: grantYamlNestedScalar(text, "subject", "alias"), custodySocket: grantYamlCustodySocket(text) };
295
366
  } catch { return {}; }
296
367
  }
368
+ function readGrantYamlCustodySocket(grantHome) {
369
+ const text = readFileSync(join(grantHome, "grant.yaml"), "utf8");
370
+ return grantYamlCustodySocket(text);
371
+ }
372
+ function verifyGrantCustodyAttachment({ grantHome, custodySocket, alias, team }) {
373
+ const recorded = readGrantYamlCustodySocket(grantHome);
374
+ if (!recorded) throw new Error(`grant.yaml custody.socket_path is missing`);
375
+ if (recorded !== custodySocket) throw new Error(`grant.yaml custody.socket_path ${recorded} differs from preflight ${custodySocket}`);
376
+ const raw = run(["aw", "custody", "status", "--json"], grantHome, 20000, { env: { AWEB_IDENTITY_HOME: grantHome }, secretSafe: true });
377
+ const status = parseAwJson(raw, "aw custody status");
378
+ if (String(status.status || "") !== "running") throw new Error(`grant custody status is ${status.status || "unknown"}, not running`);
379
+ if (status.socket_path !== custodySocket) throw new Error(`grant custody status socket_path ${status.socket_path || "<missing>"} differs from preflight ${custodySocket}`);
380
+ if (status.resident?.alias !== alias) throw new Error(`grant custody resident alias ${status.resident?.alias || "<missing>"} differs from ${alias}`);
381
+ const teamRow = (Array.isArray(status.teams) ? status.teams : []).find((t) => t && (t.team_id || t.id) === team);
382
+ if (!teamRow || teamRow.ready !== true) throw new Error(`grant custody team ${team} is not ready`);
383
+ return status;
384
+ }
385
+ function preflightCustodySocket(preflight) {
386
+ return typeof preflight.status?.socket_path === "string" && preflight.status.socket_path.trim() ? preflight.status.socket_path.trim() : undefined;
387
+ }
388
+ function requirePreflightCustodySocket(preflight) {
389
+ const socket = preflightCustodySocket(preflight);
390
+ if (!socket) fatal("custody preflight reported no socket_path, so the grant cannot be attached to custody");
391
+ return socket;
392
+ }
393
+ function grantHomeDirs() {
394
+ try {
395
+ return readdirSync(home)
396
+ .filter((name) => name === ".aweb-identity" || /^\.aweb-identity-\d+$/.test(name))
397
+ .map((name) => join(home, name))
398
+ .filter((p) => { try { return statSync(p).isDirectory(); } catch { return false; } });
399
+ } catch { return []; }
400
+ }
401
+ function removeGrantHomes() { for (const p of grantHomeDirs()) { try { rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ } } }
402
+ function newestGrantHome() {
403
+ const dirs = grantHomeDirs().map((p) => {
404
+ try { return { path: p, mtime: statSync(p).mtimeMs }; } catch { return { path: p, mtime: 0 }; }
405
+ }).sort((a, b) => b.mtime - a.mtime || b.path.localeCompare(a.path));
406
+ return dirs[0]?.path || join(home, ".aweb-identity");
407
+ }
408
+ const priorGrantHome = (meta = {}) => typeof meta.identity?.grant?.home === "string" && meta.identity.grant.home ? meta.identity.grant.home : newestGrantHome();
409
+ function retainedLaunchOutput(meta = {}, identityHome = priorGrantHome(meta)) {
410
+ const delivery = meta.delivery || deliveryMode;
411
+ const env = {};
412
+ if (delivery === "session") env.AWEB_DELIVERY = "session";
413
+ if (meta.identity?.mode === "global" && meta.identity?.grant?.id) env.AWEB_IDENTITY_HOME = identityHome;
414
+ const launch = (process.env.OATS_RUNTIME || "") === "claude" && delivery === "channel" ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" } : undefined;
415
+ return { ...(Object.keys(env).length ? { env } : {}), ...(launch ? { launch } : {}) };
416
+ }
417
+ function grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }) {
418
+ return ["aw", "id", "grant", "mint", ...(awAtLeast(GRANT_TEAM_FLAG_MIN) ? ["--team", team] : []), "--scope", scopes.join(","), "--ttl", ttl, "--label", `oats:${instance}`, "--out", grantHome, "--custody-socket", custodySocket, "--json"];
419
+ }
420
+ function validateMintedGrant(minted, grantHome) {
421
+ const grantId = typeof minted.grant_id === "string" ? minted.grant_id : undefined;
422
+ const expiresAt = typeof minted.expires_at === "string" ? minted.expires_at : undefined;
423
+ const mintedTeam = typeof minted.team_id === "string" ? minted.team_id : undefined;
424
+ const mintedOut = typeof minted.out === "string" ? minted.out : undefined;
425
+ if (!grantId || !expiresAt || !mintedTeam || !mintedOut) throw new Error("aw id grant mint JSON lacked grant_id, expires_at, team_id, or out");
426
+ if (resolve(mintedOut) !== resolve(grantHome)) throw new Error(`aw id grant mint wrote ${mintedOut}, not ${grantHome}`);
427
+ return { minted, grantId, expiresAt, mintedTeam, alias: typeof minted.alias === "string" && minted.alias ? minted.alias : undefined, address: typeof minted.address === "string" && minted.address ? minted.address : null };
428
+ }
429
+ function parseMintedGrant(raw, grantHome) { return validateMintedGrant(parseAwJson(raw, "aw id grant mint"), grantHome); }
430
+ function globalGrantRenew() {
431
+ const mode = grantRenewMode();
432
+ const oldMeta = JSON.parse(process.env.OATS_META || "{}");
433
+ if (mode === "off") out(retainedLaunchOutput(oldMeta));
434
+ if (mode !== "launch") fatal(`identity.renew must be "off" or "launch" (got ${JSON.stringify(identitySettings.renew)})`);
435
+ if (oldMeta.identity?.mode !== "global" || !oldMeta.identity?.grant?.id) out(retainedLaunchOutput(oldMeta));
436
+ const resident = String(identitySettings.resident || oldMeta.identity.resident || "");
437
+ const custody = resolveResidentCustody(resident);
438
+ const team = payloadTeam().team || oldMeta.identity.team;
439
+ if (!team) fatal("identity.renew launch needs the prior grant team or settings.oats.aweb.team");
440
+ const scopes = grantScopes();
441
+ const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
442
+ const oldHome = priorGrantHome(oldMeta);
443
+ let preflight;
444
+ try { preflight = custodyPreflight({ custody, resident, team, e2eeRequired: grantE2eeRequired(), fatalOnError: false, runAw: (argv, cwd, options) => run(argv, cwd, 60000, options), fatal }); }
445
+ catch (e) { out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal custody preflight failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` }); }
446
+ const custodySocket = preflightCustodySocket(preflight);
447
+ if (!custodySocket) out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal custody preflight reported no socket_path, so the grant cannot be attached to custody; keeping previous grant ${oldMeta.identity.grant.id}` });
448
+ if (!awAtLeast(CUSTODY_ATTACH_MIN)) out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: aw ${awVersionLabel()} cannot attach a grant to custody (--custody-socket); grants need aw >= ${CUSTODY_ATTACH_MIN}; keeping previous grant ${oldMeta.identity.grant.id}` });
449
+ let stamp = Math.floor(Date.now() / 1000);
450
+ let grantHome = join(home, `.aweb-identity-${stamp}`);
451
+ while (existsSync(grantHome)) grantHome = join(home, `.aweb-identity-${++stamp}`);
452
+ let parsed;
453
+ try {
454
+ parsed = parseMintedGrant(run(grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }), custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] }), grantHome);
455
+ } catch (e) {
456
+ try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
457
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal mint failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` });
458
+ }
459
+ const { grantId, expiresAt, mintedTeam, alias: mintedAlias, address } = parsed;
460
+ const recovered = recoverGrantHome(grantHome);
461
+ const alias = recovered.subjectAlias || mintedAlias || oldMeta.identity.alias || resident;
462
+ const newMeta = { ...oldMeta, delivery: oldMeta.delivery || deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address: address || oldMeta.identity.address || null, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
463
+ if (mintedTeam !== team) {
464
+ try { revokeGrant(custody, grantId); } catch { /* minted mismatch expires by TTL if revoke fails */ }
465
+ try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
466
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal minted grant team ${mintedTeam} differs from ${team}; keeping previous grant ${oldMeta.identity.grant.id}` });
467
+ }
468
+ try { verifyGrantCustodyAttachment({ grantHome, custodySocket, alias: newMeta.identity.alias, team: mintedTeam }); }
469
+ catch (e) {
470
+ try { revokeGrant(custody, grantId); } catch { /* new grant expires by TTL if revoke fails */ }
471
+ try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ }
472
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta, oldHome), warning: `oats-aweb: renewal grant ${grantId} custody attachment failed (${e.message || e}); keeping previous grant ${oldMeta.identity.grant.id}` });
473
+ }
474
+ let warning;
475
+ try { revokeGrant(custody, oldMeta.identity.grant.id); }
476
+ catch (e) { warning = `oats-aweb: previous grant ${oldMeta.identity.grant.id} was not revoked (${e.message || e}); new grant ${grantId} is kept and the previous grant still expires at ${oldMeta.identity.grant.expiresAt || "its TTL"}`; }
477
+ out({ meta: newMeta, ...retainedLaunchOutput(newMeta, grantHome), ...(warning ? { warning } : {}) });
478
+ }
297
479
  function globalGrantSpawn() {
298
480
  const { team, payload, env: envTeam } = payloadTeam();
299
481
  if (!team) fatal("identity.mode \"global\" requires settings.oats.aweb.team (or OATS_TEAM_ID/OATS_TEAM_NAME) before minting a grant");
@@ -301,37 +483,40 @@ function globalGrantSpawn() {
301
483
  const custody = resolveResidentCustody(resident);
302
484
  const grantHome = join(home, ".aweb-identity");
303
485
  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;
486
+ const scopes = grantScopes();
305
487
  const ttl = identitySettings.ttl === undefined || identitySettings.ttl === null || identitySettings.ttl === "" ? "8h" : String(identitySettings.ttl);
488
+ const preflight = custodyPreflight({ custody, resident, team, e2eeRequired: grantE2eeRequired(), runAw: (argv, cwd, options) => run(argv, cwd, 60000, options), fatal });
489
+ const custodySocket = requirePreflightCustodySocket(preflight);
490
+ if (!awAtLeast(CUSTODY_ATTACH_MIN)) fatal(`aw ${awVersionLabel()} cannot attach a grant to custody (--custody-socket); grants need aw >= ${CUSTODY_ATTACH_MIN}`);
306
491
  let meta;
307
492
  const cleanup = () => { try { rmSync(grantHome, { recursive: true, force: true }); } catch { /* best effort */ } };
308
493
  const failAfterMint = (message, code = 1) => { cleanup(); out({ ...(meta ? { meta } : {}), warning: `oats-aweb: ${String(message).slice(0, 300)}` }, code); };
309
494
  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"] });
495
+ const raw = run(grantMintArgv({ team, scopes, ttl, grantHome, custodySocket }), custody, 60000, { unsetEnv: ["AWEB_IDENTITY_HOME"] });
311
496
  let minted;
312
- try { minted = parseAwJson(raw, "aw id grant mint"); }
497
+ try { minted = parseMintedGrant(raw, grantHome).minted; }
313
498
  catch (parseError) {
314
499
  const recovered = recoverGrantHome(grantHome);
315
500
  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 } }) };
501
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias: resident, team: recovered.team || team, resident, grant: { id: recovered.grantId, expiresAt: recovered.expiresAt || "unknown", scopes, home: grantHome } }) };
317
502
  try { revokeGrant(custody, recovered.grantId); failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, revoked it, and removed the grant home`); }
318
503
  catch (revokeError) { failAfterMint(`${parseError.message}; recovered grant ${recovered.grantId} from grant.yaml, but revoke failed: ${revokeError.message || revokeError}`); }
319
504
  }
320
505
  throw parseError;
321
506
  }
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 } }) };
507
+ const { grantId, expiresAt, mintedTeam, alias: mintedAlias, address } = validateMintedGrant(minted, grantHome);
508
+ const recovered = recoverGrantHome(grantHome);
509
+ const alias = recovered.subjectAlias || mintedAlias || resident;
510
+ meta = { delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team: mintedTeam, address, resident, grant: { id: grantId, expiresAt, scopes, home: grantHome } }) };
331
511
  if (mintedTeam !== team) {
332
512
  try { revokeGrant(custody, grantId); failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; the grant was revoked and nothing was kept`); }
333
513
  catch (e) { failAfterMint(`minted grant team ${mintedTeam} differs from ${team}; revoke failed: ${e.message || e}`); }
334
514
  }
515
+ try { verifyGrantCustodyAttachment({ grantHome, custodySocket, alias, team: mintedTeam }); }
516
+ catch (e) {
517
+ try { revokeGrant(custody, grantId); failAfterMint(`minted grant ${grantId} custody attachment failed: ${e.message || e}; the grant was revoked and nothing was kept`); }
518
+ catch (revokeError) { failAfterMint(`minted grant ${grantId} custody attachment failed: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
519
+ }
335
520
  const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
336
521
  ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
337
522
  : undefined;
@@ -343,15 +528,16 @@ function globalGrantSpawn() {
343
528
  catch (revokeError) { failAfterMint(`session delivery registration failed for minted grant ${grantId}: ${e.message || e}; revoke failed: ${revokeError.message || revokeError}`); }
344
529
  }
345
530
  }
346
- const warnings = [];
531
+ const warnings = [...preflight.warnings];
347
532
  if (payload && envTeam && payload !== envTeam) warnings.push(`oats-aweb: settings.oats.aweb.team ${payload} differs from OATS team ${envTeam}; using payload team`);
533
+ const e2eeBrief = preflight.warnings.length ? ` Warning: ${preflight.warnings.join(" ")}` : "";
348
534
  const deliveryBrief = deliveryMode === "session"
349
535
  ? ` 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
536
  : "";
351
537
  out({
352
538
  meta,
353
539
  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.`,
540
+ 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; your grant home is attached to the resident's custody service. If a message you sent shows unverified at the receiver, report it, do not retry.${e2eeBrief}${deliveryBrief} Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
355
541
  ...(launch ? { launch } : {}),
356
542
  ...(warnings.length ? { warning: warnings.join(" | ") } : {}),
357
543
  });
@@ -368,8 +554,10 @@ function globalGrantRetire(meta) {
368
554
  const custody = resolveResidentCustody(resident);
369
555
  try {
370
556
  revokeGrant(custody, id);
557
+ removeGrantHomes();
371
558
  out({ meta: { retired: true, identityRevoked: true, grant: id } });
372
559
  } catch (e) {
560
+ removeGrantHomes();
373
561
  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
562
  }
375
563
  }
@@ -377,7 +565,7 @@ const seatLockPath = (source) => join(dirname(source), ".aw-retained-seat.json")
377
565
  /** The alias a home's .aw/workspace.yaml records under memberships (indented),
378
566
  * or undefined. Read only when the hook has no alias of its own. */
379
567
  const workspaceAliasOf = (homeDir) => {
380
- try { const m = readFileSync(join(homeDir, ".aw", "workspace.yaml"), "utf8").match(/^\s*alias:\s*["']?([a-z0-9][a-z0-9._-]{0,127})["']?\s*$/mi); return m ? m[1] : undefined; }
568
+ try { const m = readFileSync(join(homeDir, ".aw", "workspace.yaml"), "utf8").match(/^\s*alias:\s*["']?([a-z0-9][a-z0-9_-]{0,63})["']?\s*$/mi); return m ? m[1] : undefined; }
381
569
  catch { return undefined; }
382
570
  };
383
571
  /** A join that the CLI reported as failed (or that this hook killed on
@@ -411,9 +599,9 @@ function retainedSeatSpawn(source, takeOver) {
411
599
  const service = process.env.OATS_AWEB_URL || yamlScalar(srcWorkspace, "aweb_url");
412
600
  if (!service) fatal(`cannot determine the aweb service for ${source} (no aweb_url in its workspace.yaml)`);
413
601
  const role = yamlScalar(srcWorkspace, "role_name");
414
- let team = process.env.OATS_TEAM_ID;
602
+ let team = payloadTeam().team;
415
603
  if (!team && existsSync(join(source, "teams.yaml"))) team = yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active_team") || yamlScalar(readFileSync(join(source, "teams.yaml"), "utf8"), "active");
416
- if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (set team.id in oats-config.yaml, or an active team in ${join(source, "teams.yaml")})`);
604
+ if (!team || !team.includes(":")) fatal(`cannot determine the team for the retained identity (${teamConfigRemedy()}, or keep an active team in ${join(source, "teams.yaml")})`);
417
605
  const dest = join(home, ".aw");
418
606
  const legacyHome = dirname(source);
419
607
  // The lock is taken FIRST: a concurrent second spawn must see it before any
@@ -483,14 +671,14 @@ function retainedSeatSpawn(source, takeOver) {
483
671
  if (expectedDid && shownDid !== expectedDid) throw new Error(`aw whoami shows did ${shownDid || "(none)"}, not the retained identity's ${expectedDid}; the seat is not the same identity`);
484
672
  if (expectedAddress && shownAddress !== expectedAddress) throw new Error(`aw whoami shows address ${shownAddress || "(none)"}, not the retained identity's ${expectedAddress}; the seat is not the same identity`);
485
673
  const aliasRaw = String(ws.alias || st.alias || (expectedAddress || "").split("/").pop() || instance);
486
- if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
674
+ if (!AWEB_ALIAS_RE.test(aliasRaw)) throw new Error(`aw workspace status reports an alias that is not a plausible alias; the seat is not briefed`);
487
675
  const alias = aliasRaw;
488
676
  if (expectedAddress && !expectedAddress.endsWith(`/${alias}`)) throw new Error(`aw workspace status shows alias ${alias}, not the retained identity's address ${expectedAddress}; the seat is not the same identity`);
489
677
  writeFileSync(lockPath, JSON.stringify({ home, instance, alias, team, takenAt: new Date().toISOString(), host: hostname(), ...(takenOver ? { tookOverFrom: takenOver } : {}) }, null, 2) + "\n", { mode: 0o600 });
490
678
  const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
491
679
  ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
492
680
  : undefined;
493
- const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
681
+ const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: dest };
494
682
  const deliveryBrief = deliveryMode === "session"
495
683
  ? ` Notification delivery: external (AWEB_DELIVERY=session); until the host wake broker registers this instance NOTHING wakes you: check \`aw mail inbox\` and \`aw chat pending\` at every task boundary.`
496
684
  : "";
@@ -500,7 +688,7 @@ function retainedSeatSpawn(source, takeOver) {
500
688
  if (hostNote) warnings.push(`oats-aweb: seated${hostNote}`);
501
689
  out({
502
690
  meta: { team, alias, retained: true, source, lock: lockPath, delivery: deliveryMode, identity: identityMeta({ mode: "global", alias, team, address: shownAddress || expectedAddress || null }), ...(takenOver ? { tookOverFrom: takenOver } : {}) },
503
- ...(env ? { env } : {}),
691
+ env,
504
692
  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).`,
505
693
  ...(launch ? { launch } : {}),
506
694
  ...(warnings.length ? { warning: warnings.join(" | ") } : {}),
@@ -511,13 +699,20 @@ function retainedSeatSpawn(source, takeOver) {
511
699
  }
512
700
  }
513
701
 
514
- if (event === "spawn") {
702
+ if (event === "launch") {
703
+ if (identityMode === "global" || grantRenewMode() === "launch") globalGrantRenew();
704
+ out(retainedLaunchOutput(JSON.parse(process.env.OATS_META || "{}")));
705
+ } else if (event === "spawn") {
515
706
  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
707
  if (identityMode === "global") globalGrantSpawn();
517
708
  if (identityMode === "local" && settings.identity && typeof settings.identity === "object" && settings.identity.source) retainedSeatSpawn(String(settings.identity.source), settings.identity.takeOver === true);
518
709
  let minted; // external identity, once `aw team join` succeeds
519
710
  const root = awebRoot();
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`);
711
+ if (!root) {
712
+ const candidate = rootSettingCandidate();
713
+ const classicHint = isClassicDeployment() ? " (classic fallback also checked the bounded team-scope candidates)" : "";
714
+ fatal(`${awebRootProblem(candidate)}, so no identity could be minted and this instance would have no messaging${classicHint}`);
715
+ }
521
716
  try {
522
717
  // Team correctness: the config's `team:` block wins (id, then name), else the
523
718
  // root's active team. ALWAYS pass --team-id explicitly — never inherit whatever
@@ -527,15 +722,16 @@ if (event === "spawn") {
527
722
  const resolvedTeam = payloadTeam();
528
723
  let team = resolvedTeam.team;
529
724
  const teamPayloadMismatch = resolvedTeam.payload && resolvedTeam.env && resolvedTeam.payload !== resolvedTeam.env;
725
+ if (!team && process.env.OATS_TEAM_LABEL) fatal(`cannot determine target team for workspace team label ${JSON.stringify(process.env.OATS_TEAM_LABEL)}, so no identity could be minted — ${teamConfigRemedy()}`);
530
726
  if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
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");
727
+ if (!team) fatal(`cannot determine target team, so no identity could be minted — ${teamConfigRemedy()}, or activate a team at the aweb root`);
532
728
  // A bare team name (no namespace) resolves against the root's memberships.
533
729
  if (!team.includes(":")) {
534
730
  const teams = JSON.parse(run(["aw", "team", "list", "--json"], root));
535
731
  const match = teamIdsOf(teams).filter((tid) => String(tid).startsWith(`${team}:`));
536
732
  if (match.length === 1) team = match[0];
537
- else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — set team.id in oats-config.yaml`);
538
- else fatal(`no membership matching team "${team}" at ${root}, so no identity could be minted — join or create it first (aweb-team-membership skill), or set team.id`);
733
+ else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — ${teamConfigRemedy()}`);
734
+ else fatal(`no membership matching team "${team}" at ${root}, so no identity could be minted — join or create it first (aweb-team-membership skill), or ${teamConfigRemedy()}`);
539
735
  }
540
736
  // Both of these carry the invite token — one mints it, the other spends it —
541
737
  // so neither their output nor their diagnostics may reach a log.
@@ -559,7 +755,7 @@ if (event === "spawn") {
559
755
  // re-spawn under the same name is refused by AWID. Say that, and the
560
756
  // remedy, instead of relaying a bare join error.
561
757
  if (e.aliasConflict) {
562
- fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — spawn with a fresh --purpose instead`);
758
+ fatal(`alias "${instance}" already holds a certificate on ${team} (a retired instance of that name is not reusable until aweb-abim ships), so no identity could be minted — ${ALIAS_REUSE_REMEDY}`);
563
759
  }
564
760
  throw e;
565
761
  }
@@ -572,7 +768,7 @@ if (event === "spawn") {
572
768
  // token; otherwise fall back to what WE asked for, which is always known.
573
769
  const clean = (v) => (typeof v === "string" && v.trim() && !v.includes(inv.token) ? v.trim() : undefined);
574
770
  const joined = {
575
- alias: (() => { const a = clean(raw.alias); return a && /^[a-z0-9][a-z0-9._-]{0,127}$/i.test(a) ? a : instance; })(),
771
+ alias: (() => { const a = clean(raw.alias); return a && AWEB_ALIAS_RE.test(a) ? a : instance; })(),
576
772
  // Team ids are "<name>:<domain>"; anything else is not one, and the
577
773
  // requested team is the honest fallback.
578
774
  team_id: (() => { const t = clean(raw.team_id); return t && /^[^\s:]+:[^\s:]+$/.test(t) ? t : team; })(),
@@ -597,7 +793,7 @@ if (event === "spawn") {
597
793
  const launch = (process.env.OATS_RUNTIME || "") === "claude" && deliveryMode === "channel"
598
794
  ? { claude: "--dangerously-load-development-channels plugin:aweb-channel@awebai-marketplace" }
599
795
  : undefined;
600
- const env = deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : undefined;
796
+ const env = { ...(deliveryMode === "session" ? { AWEB_DELIVERY: "session" } : {}), AWEB_IDENTITY_HOME: join(home, ".aw") };
601
797
  const channelWarning = undefined;
602
798
  if (deliveryMode === "session") wakeRegister(home, join(home, ".aw"));
603
799
  const deliveryBrief = deliveryMode === "session"
@@ -605,7 +801,7 @@ if (event === "spawn") {
605
801
  : "";
606
802
  out({
607
803
  meta: { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) },
608
- ...(env ? { env } : {}),
804
+ env,
609
805
  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.`,
610
806
  ...(launch ? { launch } : {}),
611
807
  ...(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 } : {}),
@@ -621,7 +817,7 @@ if (event === "spawn") {
621
817
  // A retained seat: release the lock and leave the identity alone. Never
622
818
  // aw workspace delete (it would soft-delete the standing identity's row)
623
819
  // and never team retire; the source .aw stays until a human removes it.
624
- 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"); }
820
+ if (meta.delivery === "session" && (meta.retained || meta.identity?.mode !== "global")) { if (!wakeDeregister(home)) process.stderr.write("oats-aweb: aw wake deregister failed; the broker treats a retired home as inactive on its own\n"); }
625
821
  if (meta.retained) {
626
822
  if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
627
823
  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}` : ""}` });
@@ -653,14 +849,14 @@ if (event === "spawn") {
653
849
  // aw 1.36.1 prints the cause as alias_released_reason (workspace.go,
654
850
  // workspace_self_retire.go); `reason` is tolerated for a later rename.
655
851
  const reason = typeof doc?.alias_released_reason === "string" ? doc.alias_released_reason : typeof doc?.reason === "string" ? doc.reason : (doc ? "unstated" : "no JSON answer");
656
- out({ meta: { retired: true, aliasReusable: released, aliasReason: reason }, ...(released ? {} : { warning: `oats-aweb: workspace "${meta.alias}" deleted but its alias was not released (${reason}); spawn successors with a fresh --purpose until it is` }) });
852
+ out({ meta: { retired: true, aliasReusable: released, aliasReason: reason }, ...(released ? {} : { warning: `oats-aweb: workspace "${meta.alias}" deleted but its alias was not released (${reason}); spawn successors with a different --name (kernels 0.26.0+) or a different --purpose until it is` }) });
657
853
  }
658
854
  run(["aw", "workspace", "delete", meta.alias], home);
659
855
  // Honest: the workspace row is deleted, but a hosted local member cannot
660
856
  // revoke its own AWID certificate (aweb-abim), so the alias is NOT
661
857
  // reusable. retired stays true because the cleanup is as complete as the
662
858
  // platform allows; the field and the line carry the truth.
663
- out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a fresh --purpose` });
859
+ out({ meta: { retired: true, aliasReusable: false }, warning: `oats-aweb: workspace "${meta.alias}" deleted; its certificate is not revoked (aweb-abim), so the alias is not reusable — spawn successors with a different --name (kernels 0.26.0+) or a different --purpose` });
664
860
  } catch (e) {
665
861
  // Exit nonzero: during a required-hook rollback this is the signal that
666
862
  // compensation did NOT complete, so the spawn is not reported as cleanly
@@ -673,9 +869,9 @@ if (event === "spawn") {
673
869
  // wherever they run (plus human members). Local liveness comes from
674
870
  // `oats status --team`; this is the network view.
675
871
  const root = awebRoot();
676
- if (!root) { console.error("oats aweb roster: no initialized aweb root (.aw) found"); process.exit(1); }
872
+ if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate())}`); process.exit(1); }
677
873
  const team = process.env.OATS_TEAM_ID || process.env.OATS_TEAM_NAME || JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
678
- if (!team) { console.error("oats aweb roster: cannot determine team (no config team block, no active team)"); process.exit(1); }
874
+ if (!team) { console.error(`oats aweb roster: cannot determine team (${teamConfigRemedy()}, or activate a team at the aweb root)`); process.exit(1); }
679
875
  const teamFlag = team.includes(":") ? ["--team-id", team] : ["--team", team];
680
876
  const r = JSON.parse(run(["aw", "id", "team", "members", ...teamFlag, "--json"], root, 60000));
681
877
  if (process.argv.includes("--json")) { console.log(JSON.stringify(r, null, 2)); process.exit(0); }
@@ -686,39 +882,105 @@ if (event === "spawn") {
686
882
  console.log("\nAliases minted by OATS are instance names; message one with `aw mail send --to <alias> --subject \"...\" --body \"...\"`.");
687
883
  process.exit(0);
688
884
  } else if (event === "setup") {
689
- // Guided onboarding — idempotent, prints what it finds and the one next step.
690
- const scope = process.env.OATS_TEAM_SCOPE || process.cwd();
691
- const teamName = process.env.OATS_TEAM_NAME;
692
- const teamId = process.env.OATS_TEAM_ID;
693
- console.log(`aweb onboarding — team scope: ${scope}${teamName ? `, config team: ${teamName}${teamId ? ` (${teamId})` : ""}` : ""}\n`);
694
- if (!teamName) {
695
- console.log("1. Declare your team in the deployment scope's oats-config.yaml first:");
696
- console.log(" team:\n name: <your-team>\n then re-run `oats aweb setup` from there.");
697
- process.exit(0);
885
+ // Guided onboarding — idempotent, prints what it finds and can run one
886
+ // existing aw primitive when the operator supplies the needed authority.
887
+ const args = process.argv.slice(3).filter((arg) => arg !== "--json");
888
+ const usage = "usage: oats aweb setup [--username <hosted-user> | --invite <token>]";
889
+ let username, invite;
890
+ for (let i = 0; i < args.length; i++) {
891
+ const arg = args[i];
892
+ if (arg === "--username" && args[i + 1]) { username = args[++i]; continue; }
893
+ if (arg.startsWith("--username=") && arg.length > "--username=".length) { username = arg.slice("--username=".length); continue; }
894
+ if (arg === "--invite" && args[i + 1]) { invite = args[++i]; continue; }
895
+ if (arg.startsWith("--invite=") && arg.length > "--invite=".length) { invite = arg.slice("--invite=".length); continue; }
896
+ console.error(`oats aweb setup: ${usage}`);
897
+ process.exit(2);
698
898
  }
699
- if (!existsSync(join(scope, ".aw"))) {
700
- console.log(`No aweb workspace at the team scope yet. Initialize it (interactive — creates or connects an aweb account):`);
701
- console.log(` cd ${scope} && aw init`);
702
- console.log(" First time on aweb? `aw init` walks you through creating a hosted aweb.ai account.");
703
- console.log(" Own your domain? Use `aw init --byod` (see the aweb-team-membership skill).");
899
+ const apiKey = !!process.env.AWEB_API_KEY;
900
+ const actions = [username ? "--username" : null, invite ? "--invite" : null, apiKey ? "AWEB_API_KEY" : null].filter(Boolean);
901
+ if (actions.length > 1) { console.error(`oats aweb setup: choose exactly one onboarding authority (${actions.join(", ")})\n${usage}`); process.exit(2); }
902
+
903
+ const resolvedTeam = payloadTeam();
904
+ const teamName = resolvedTeam.team;
905
+ const teamId = typeof settings.team === "string" && settings.team.trim() ? settings.team.trim() : process.env.OATS_TEAM_ID;
906
+ const candidate = rootSettingCandidate(teamName);
907
+ const scope = isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined
908
+ ? resolve(process.env.OATS_TEAM_SCOPE || process.cwd())
909
+ : (candidate?.root ? resolve(candidate.root) : process.cwd());
910
+ const classic = isClassicDeployment() && settings.root === undefined && settings.roots === undefined && settings.team === undefined;
911
+ console.log(`aweb onboarding — ${classic ? "team scope" : "messaging root"}: ${scope}${teamName ? `, team: ${teamName}` : ""}\n`);
912
+ if (!isAbsolute(scope)) {
913
+ console.log(`${candidate?.key || "settings.oats.aweb.root"} must be an absolute directory whose .aw is the aweb minting root.`);
704
914
  process.exit(0);
705
915
  }
706
- let teams = { memberships: [] };
707
- try { teams = JSON.parse(run(["aw", "team", "list", "--json"], scope)); } catch { /* fall through */ }
916
+
708
917
  const want = teamId || teamName;
709
- const match = teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`));
710
- if (match) {
711
- console.log(`✓ aweb workspace initialized and member of ${match}.`);
712
- if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
713
- console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
714
- console.log(" Roster: `oats aweb roster` · local: `oats status --team`");
715
- } else {
716
- console.log(`Workspace initialized, but no membership matching "${want}".`);
717
- console.log(` Create the team: cd ${scope} && aw team create ${teamName}`);
718
- console.log(" Or join an existing one: get an invite token from a member, then `aw team join <token>`");
719
- console.log(" (details: aweb-team-membership skill)");
918
+ const defaultTeamForUsername = username ? `default:${username}.aweb.ai` : undefined;
919
+ const readTeams = () => {
920
+ try { return parseAwJson(run(["aw", "team", "list", "--json"], scope), "aw team list"); }
921
+ catch { return { memberships: [] }; }
922
+ };
923
+ const matchingTeam = (teams) => want ? teamIdsOf(teams).find((tid) => String(tid) === want || String(tid).startsWith(`${want}:`)) : undefined;
924
+ const printVerdict = (teams) => {
925
+ const match = matchingTeam(teams);
926
+ if (match) {
927
+ console.log(`readiness: ready`);
928
+ console.log(`✓ aweb workspace initialized and member of ${match}.`);
929
+ if (teams.active_team && teams.active_team !== match) console.log(` Note: active team is ${teams.active_team}; instances join ${match} explicitly, but consider \`aw team switch ${match}\`.`);
930
+ console.log(" Done — spawned instances will join this team automatically (alias = instance name).");
931
+ console.log(" Roster: `oats aweb roster` · local: `oats status --team`");
932
+ return;
933
+ }
934
+ console.log(`readiness: needs-configuration`);
935
+ if (!want) {
936
+ console.log(` no team: ${teamConfigRemedy()}`);
937
+ const active = teams.active_team || teamIdsOf(teams)[0];
938
+ if (active) console.log(` This root is a member of ${active}; map the workspace team to that id in the workspace file or settings.oats.aweb.team.`);
939
+ else if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; map the workspace team to that id if this is the intended team.`);
940
+ return;
941
+ }
942
+ console.log(` Workspace initialized, but no membership matching "${want}".`);
943
+ if (defaultTeamForUsername) console.log(` New hosted users create ${defaultTeamForUsername}; set messaging.byTeam.<label>.team or settings.oats.aweb.team to that id, then re-run setup.`);
944
+ console.log(" Existing team path: ask a member for an invite token, then run `oats aweb setup --invite <token>` (uses `aw team join <token>` at the root).");
945
+ console.log(" Team API-key path: set AWEB_API_KEY in the environment and run `oats aweb setup` (uses `aw init` at the root; the key is never printed).");
946
+ console.log(" New hosted-account path: run `oats aweb setup --username <u>` (uses `aw init --username <u>` and creates default:<u>.aweb.ai).");
947
+ };
948
+
949
+ try {
950
+ const hasRoot = existsSync(join(scope, ".aw"));
951
+ if (!hasRoot && !actions.length) {
952
+ console.log(`No aweb workspace at the ${classic ? "team scope" : "messaging root"} yet (${candidate?.key || "settings.oats.aweb.root"}).`);
953
+ if (!want) console.log(` Also choose the aweb team for this deployment: ${teamConfigRemedy()}.`);
954
+ console.log(" Choose one guided setup path:");
955
+ console.log(" oats aweb setup --username <u> # runs `aw init --username <u>` and creates default:<u>.aweb.ai");
956
+ console.log(" AWEB_API_KEY=<key> oats aweb setup # runs `aw init` for the hosted team behind the key");
957
+ console.log(" oats aweb setup --invite <token> # runs `aw team join <token>` from an existing-team invite");
958
+ console.log(" Or set settings.oats.aweb.root to an absolute directory whose .aw is the aweb minting root, then re-run setup.");
959
+ console.log(" Own your domain? Use the aweb-team-membership skill for BYOT flows.");
960
+ process.exit(0);
961
+ }
962
+ let teams = hasRoot ? readTeams() : { memberships: [] };
963
+ if (hasRoot && !matchingTeam(teams) && !actions.length) { printVerdict(teams); process.exit(0); }
964
+ if (!hasRoot || !matchingTeam(teams)) {
965
+ if (actions.length) mkdirSync(scope, { recursive: true });
966
+ if (username) {
967
+ console.log(`Running aw init --username <u> at ${scope} (username withheld from repeated logs).`);
968
+ run(["aw", "init", "--username", username], scope, 120000, { secrets: [username], unsetEnv: ["AWEB_API_KEY"] });
969
+ } else if (invite) {
970
+ console.log(`Running aw team join <token> at ${scope} (token withheld).`);
971
+ run(["aw", "team", "join", invite], scope, 120000, { secrets: [invite], secretSafe: true });
972
+ } else if (apiKey) {
973
+ console.log(`Running aw init at ${scope} with AWEB_API_KEY from the environment (key withheld).`);
974
+ run(["aw", "init"], scope, 120000, { secretSafe: true });
975
+ }
976
+ teams = readTeams();
977
+ }
978
+ printVerdict(teams);
979
+ process.exit(0);
980
+ } catch (e) {
981
+ console.error(`oats aweb setup: ${e.message || e}`);
982
+ process.exit(1);
720
983
  }
721
- process.exit(0);
722
984
  } else {
723
985
  warn(`unknown event "${event}" (expected spawn|retire)`);
724
986
  }