@awebai/oats 0.29.0 → 0.29.2

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
@@ -958,7 +958,8 @@ function instanceCmd() {
958
958
  console.log(` upstream: ${observed.upstream.ref ? `${observed.upstream.ref} +${observed.upstream.ahead} -${observed.upstream.behind}` : "none (ahead/behind unknown)"}`);
959
959
  console.log(` base: ${observed.base.ref ? `${observed.base.ref} +${observed.base.ahead} -${observed.base.behind} (merge-base ${observed.base.mergeBase?.slice(0, 12)})` : "unknown"}`);
960
960
  console.log(` files: ${observed.files.length} (${Object.entries(observed.summary).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(", ") || "clean"})`);
961
- for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path} [${f.id}]`);
961
+ const lines = (f) => (f.binary ? " binary" : Number.isInteger(f.additions) ? ` +${f.additions} -${f.deletions}` : "");
962
+ for (const f of observed.files) console.log(` ${f.xy} ${f.origPath ? `${f.origPath} -> ` : ""}${f.path}${lines(f)} [${f.id}]`);
962
963
  for (const n of observed.notes) console.log(` note: ${n}`);
963
964
  return;
964
965
  }
@@ -37,7 +37,7 @@
37
37
  * identity joined moments before the failure must still be deletable.
38
38
  */
39
39
  import { execFileSync } from "node:child_process";
40
- import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
40
+ import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
41
41
  import { hostname } from "node:os";
42
42
  import { join, dirname, resolve, delimiter, isAbsolute } from "node:path";
43
43
  import { loadCapturedAwebExecution, requireCapturedAwebAction } from "../lib/captured-execution.mjs";
@@ -45,17 +45,24 @@ import { assessCapturedSessionReadiness, querySelectedKernel } from "../lib/sess
45
45
  import { runCapturedNative } from "../lib/captured-native.mjs";
46
46
  import { CUSTODY_ATTACH_MIN, grantYamlCustodySocket, parseBindingJson } from "../lib/binding-wire.mjs";
47
47
  import { custodyPreflight } from "../lib/grant-custody.mjs";
48
+ import { PERSONAL_ROOT_DEFERRED_WARNING, personalRootDeclared } from "../lib/personal-team.mjs";
49
+ import { runtimeDeliveryFor, wakeRegistration } from "../lib/wake-receive.mjs";
48
50
 
49
51
  /** Run a command as ARGV — never a shell string. Team ids, aliases, instance
50
52
  * names and invite tokens all flow through here; quoting them correctly is a
51
53
  * property of one helper staying correct forever, while argv removes the class.
52
54
  * This hook is a REQUIRED spawn hook, so it gates every spawn, which is reason
53
55
  * enough not to rely on quoting. */
54
- const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false, env: extraEnv, unsetEnv = [] } = {}) => {
56
+ const run = (argv, cwd, timeout = 45000, { secrets = [], secretSafe = false, env: extraEnv, unsetEnv = [], input } = {}) => {
55
57
  try {
56
- const childEnv = extraEnv || unsetEnv.length ? { ...process.env, ...(extraEnv || {}) } : undefined;
57
- for (const name of unsetEnv) if (childEnv) delete childEnv[name];
58
- return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout, ...(childEnv ? { env: childEnv } : {}) }).trim();
58
+ // An inherited AWEB_IDENTITY_HOME (every aweb instance session carries its
59
+ // own) is never this hook's identity: aw would act as the CALLER — refusing
60
+ // cwd-rooted commands such as team invite/list, or deleting the caller's
61
+ // workspace when a lead retires a worker. Only an explicit env sets one.
62
+ const childEnv = { ...process.env, ...(extraEnv || {}) };
63
+ if (!extraEnv || !Object.hasOwn(extraEnv, "AWEB_IDENTITY_HOME")) delete childEnv.AWEB_IDENTITY_HOME;
64
+ for (const name of unsetEnv) delete childEnv[name];
65
+ return execFileSync(argv[0], argv.slice(1), { cwd, encoding: "utf8", stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"], ...(input === undefined ? {} : { input }), timeout, env: childEnv }).trim();
59
66
  } catch (e) {
60
67
  // execFileSync puts the WHOLE ARGV in e.message ("Command failed: aw team
61
68
  // join <token> …"). This hook's failures are reported by the kernel and land
@@ -118,7 +125,43 @@ function onPath(cmd) {
118
125
  }
119
126
  return false;
120
127
  }
121
- const out = (o, code = 0) => { process.stdout.write(JSON.stringify(o) + "\n"); process.exit(code); };
128
+ // Kernel home operations (`oats operation run messaging:teams|join|leave`, run
129
+ // with OATS_OPERATION and --json) read stdout as EXACTLY ONE JSON-v1 envelope
130
+ // whose `ok` agrees with the exit status (oats bin/oats.mjs finishOperation):
131
+ // {schemaVersion:1, ok:true, result} with exit 0, or {schemaVersion:1, ok:false,
132
+ // error:{code, message}} with a nonzero exit. Under OATS_OPERATION every other
133
+ // stdout write goes to stderr and every way out answers one envelope. Without
134
+ // it (`oats aweb teams --json` from a shell) the bare document is unchanged.
135
+ const OPERATION_COMMANDS = ["teams", "join", "leave"];
136
+ const operation = process.env.OATS_OPERATION && OPERATION_COMMANDS.includes(process.env.OATS_EVENT || process.argv[2]) ? process.env.OATS_OPERATION : undefined;
137
+ let operationAnswered = false;
138
+ let lastStderr = "";
139
+ const operationEnvelopeFailure = (code, message, details) => ({ schemaVersion: 1, ok: false, error: { code: code || "E_OPERATION_FAILED", message: String(message || "failed").slice(0, 1000), ...(details ? { details } : {}) } });
140
+ function answerOperation(envelope, exitCode) {
141
+ operationAnswered = true;
142
+ writeSync(1, JSON.stringify(envelope) + "\n");
143
+ process.exit(exitCode);
144
+ }
145
+ const operationOk = (result) => answerOperation({ schemaVersion: 1, ok: true, result }, 0);
146
+ const operationFail = (code, message, details) => answerOperation(operationEnvelopeFailure(code, message, details), 1);
147
+ if (operation) {
148
+ const toStderr = process.stderr.write.bind(process.stderr);
149
+ process.stderr.write = (chunk, ...rest) => { if (String(chunk).trim()) lastStderr = String(chunk).trim(); return toStderr(chunk, ...rest); };
150
+ process.stdout.write = (chunk, ...rest) => process.stderr.write(chunk, ...rest);
151
+ // Any exit that did not answer (a refusal printed to stderr, an uncaught
152
+ // error) still answers one failure envelope, and never exits 0.
153
+ process.on("exit", (code) => {
154
+ if (operationAnswered) return;
155
+ operationAnswered = true;
156
+ writeSync(1, JSON.stringify(operationEnvelopeFailure("E_OPERATION_FAILED", lastStderr || `oats-aweb ${operation} exited ${code} without an answer`)) + "\n");
157
+ if (!code) process.exitCode = 1;
158
+ });
159
+ }
160
+ const out = (o, code = 0) => {
161
+ if (operation) operationFail("E_OPERATION_FAILED", String(o?.warning || o?.problems?.[0]?.message || "failed").replace(/^oats-aweb: /, ""));
162
+ process.stdout.write(JSON.stringify(o) + "\n");
163
+ process.exit(code);
164
+ };
122
165
  const warn = (m) => out({ warning: `oats-aweb: ${String(m).slice(0, 300)}` });
123
166
  /** Fatal for a REQUIRED spawn hook: emit metadata for compensation, then exit
124
167
  * nonzero so the kernel rolls the spawn back. `meta` carries whatever external
@@ -735,14 +778,14 @@ const providerTeamsFile = () => join(providerStateDir(), "teams.json");
735
778
  function readProviderTeamsState(meta = {}) {
736
779
  try {
737
780
  const doc = JSON.parse(readFileSync(providerTeamsFile(), "utf8"));
738
- return { joinedTeams: joinedTeamsOf(doc) };
739
- } catch { return { joinedTeams: joinedTeamsOf(meta) }; }
781
+ return { joinedTeams: joinedTeamsOf(doc), wakeJoined: doc.wakeJoined === true };
782
+ } catch { return { joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true }; }
740
783
  }
741
784
  function writeProviderTeamsState(meta) {
742
785
  mkdirSync(providerStateDir(), { recursive: true, mode: 0o700 });
743
- writeFileSync(providerTeamsFile(), JSON.stringify({ joinedTeams: joinedTeamsOf(meta) }, null, 2) + "\n", { mode: 0o600 });
786
+ writeFileSync(providerTeamsFile(), JSON.stringify({ joinedTeams: joinedTeamsOf(meta), wakeJoined: meta.wakeJoined === true }, null, 2) + "\n", { mode: 0o600 });
744
787
  }
745
- function withProviderTeams(meta = {}) { return { ...meta, joinedTeams: readProviderTeamsState(meta).joinedTeams }; }
788
+ function withProviderTeams(meta = {}) { const state = readProviderTeamsState(meta); return { ...meta, joinedTeams: state.joinedTeams, wakeJoined: state.wakeJoined }; }
746
789
  function identityHomeForLabel(label) { return join(home, `.aweb-identity-${label}`); }
747
790
  function awWithIdentity(identityHome, args) { return ["aw", "--identity-home", identityHome, ...args]; }
748
791
  function commandOutput(e) { return [e?.stdout, e?.stderr, e?.message].filter(Boolean).join("\n"); }
@@ -763,9 +806,9 @@ function teamsDocument(meta = readCapabilityMeta()) {
763
806
  const joined = joinedTeamsOf(meta);
764
807
  const joinedLabels = new Set(joined.map((j) => j.label));
765
808
  const primary = primaryTeamLabel();
766
- const personalTeam = meta.team || meta.identity?.team || payloadTeam().team || null;
809
+ const personalTeam = meta.personal?.team || meta.team || meta.identity?.team || payloadTeam().team || null;
767
810
  return {
768
- personal: { team: personalTeam },
811
+ personal: { team: personalTeam, ...(meta.personal?.source ? { source: meta.personal.source } : {}) },
769
812
  primary,
770
813
  eligible: teams.filter((t) => t.mapped && t.team).map((t) => ({ label: t.label, team: t.team, joined: joinedLabels.has(t.label) })),
771
814
  joined: joined.map((j) => ({ label: j.label, team: j.team, identityHome: j.identityHome, receive: j.receive || "poll", since: j.since })),
@@ -855,36 +898,132 @@ function runTeamsCommand(kind) {
855
898
  const actions = [];
856
899
  const warnings = [];
857
900
  if (kind === "join" || kind === "leave") {
901
+ if (identityMode === "global" || meta.identity?.mode === "global") {
902
+ const error = new Error('joined teams need local per-team identities; this home acts as a resident identity through a session grant (identity.mode "global")');
903
+ error.code = "E_TEAM_GLOBAL_MODE";
904
+ throw error;
905
+ }
858
906
  const rows = validateJoinLabels(args.labels, { action: kind });
859
- if (!rows.length) throw new Error("labels are required");
860
- for (const row of rows) {
861
- const result = kind === "join" ? mintJoinedTeam(row, meta) : leaveJoinedTeam(row.label, meta);
862
- meta = result.meta;
863
- actions.push({ action: kind, label: row.label, ...(result.released ? { released: result.released } : {}), ...(result.receipt ? { receipt: result.receipt } : {}), ...(result.warning ? { warning: result.warning } : {}) });
864
- if (result.warning) warnings.push(`oats-aweb: ${result.warning}`);
907
+ if (!rows.length) { const error = new Error("labels are required"); error.code = "E_BAD_ARGS"; throw error; }
908
+ // Record every completed label even when a later one fails: a confirmed
909
+ // leave deleted its home, and a join created a remote identity.
910
+ try {
911
+ for (const row of rows) {
912
+ const result = kind === "join" ? mintJoinedTeam(row, meta) : leaveJoinedTeam(row.label, meta);
913
+ meta = result.meta;
914
+ writeProviderTeamsState(meta);
915
+ actions.push({ action: kind, label: row.label, ...(result.released ? { released: result.released } : {}), ...(result.receipt ? { receipt: result.receipt } : {}), ...(result.warning ? { warning: result.warning } : {}) });
916
+ if (result.warning) warnings.push(`oats-aweb: ${result.warning}`);
917
+ }
918
+ } catch (e) {
919
+ // What already happened travels with the failure, so a caller can
920
+ // reconcile (the operation envelope carries it as error.details).
921
+ e.partial = { actions };
922
+ throw e;
923
+ } finally {
924
+ const synced = syncWakeReceive(meta);
925
+ meta = synced.meta;
926
+ for (const w of synced.warnings) warnings.push(`oats-aweb: ${w}`);
927
+ writeProviderTeamsState(meta);
865
928
  }
866
- writeProviderTeamsState(meta);
867
929
  }
868
930
  const doc = { ...teamsDocument(meta), ...(actions.length ? { actions } : {}), ...(warnings.length ? { warnings } : {}) };
931
+ if (operation) operationOk(doc);
869
932
  outputTeamsDocument(doc, args.json);
870
933
  }
871
934
 
935
+ /** The primary identity's team and the root that mints it, exactly as 1.14.2:
936
+ * the config's `team:` (id, then name) wins, else the root's active team.
937
+ * Per-workspace personal-team enrollment is deferred to 1.16, so this makes no
938
+ * `aw auth` or `aw team ensure` call and a declared roots.personal is ignored
939
+ * with a warning. Exits through fatal() on refusal. */
940
+ function resolvePrimaryTeam() {
941
+ const warnings = [];
942
+ const root = awebRoot();
943
+ if (!root) fatal(`${awebRootProblem(rootSettingCandidate())}, so no identity could be minted and this instance would have no messaging`);
944
+ let team = payloadTeam().team;
945
+ const source = team ? "setting" : "root";
946
+ const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
947
+ if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
948
+ if (unmappedPrimary && team) warnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using personal team ${team}`);
949
+ if (!team) fatal(`cannot determine target team, so no identity could be minted — ${teamConfigRemedy()}, or activate a team at the aweb root`);
950
+ // A bare team name (no namespace) resolves against the root's memberships.
951
+ if (!team.includes(":")) {
952
+ const teams = JSON.parse(run(["aw", "team", "list", "--json"], root));
953
+ const match = teamIdsOf(teams).filter((tid) => String(tid).startsWith(`${team}:`));
954
+ if (match.length === 1) team = match[0];
955
+ else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — ${teamConfigRemedy()}`);
956
+ 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()}`);
957
+ }
958
+ if (personalRootDeclared(settings)) warnings.push(`oats-aweb: personal-root-deferred — ${PERSONAL_ROOT_DEFERRED_WARNING}`);
959
+ return { team, root, source, warnings };
960
+ }
961
+
962
+ /** The home's runtime. Spawn and launch run in the session's own env; a
963
+ * join/leave may be run by another agent whose OATS_RUNTIME is its own. */
964
+ function instanceRuntime(meta = {}) {
965
+ const own = ["spawn", "launch"].includes(event) ? process.env.OATS_RUNTIME : undefined;
966
+ if (own) return own;
967
+ if (meta.runtime) return meta.runtime;
968
+ try { const r = JSON.parse(readFileSync(join(home, "instance.json"), "utf8")).runtime; if (r) return r; } catch { /* fall through */ }
969
+ return process.env.OATS_RUNTIME || undefined;
970
+ }
971
+ function primaryIdentityHomeOf(meta = {}) {
972
+ return meta.identity?.mode === "global" && meta.identity?.grant?.home ? meta.identity.grant.home : join(home, ".aw");
973
+ }
974
+ /** Keep the host wake broker's registration for this home in step with the
975
+ * joined teams, and record each joined team's receive mode. Never throws: a
976
+ * refused registration leaves that team poll-only with a warning. */
977
+ function syncWakeReceive(meta) {
978
+ // A joined home that no longer exists would make aw refuse the whole
979
+ // registration; it is stale provider state, not a receive identity.
980
+ const joined = joinedTeamsOf(meta).filter((j) => existsSync(j.identityHome));
981
+ const delivery = meta.delivery || deliveryMode;
982
+ const runtime = instanceRuntime(meta);
983
+ const primary = primaryIdentityHomeOf(meta);
984
+ const doc = wakeRegistration({ home, primaryIdentityHome: primary, delivery, runtime, joined, backend: process.env.OATS_BACKEND });
985
+ const warnings = [];
986
+ let receive = "poll", wakeJoined = false;
987
+ if (doc) {
988
+ try { run(["aw", "wake", "register", "--registration-json", "-"], home, 60000, { input: JSON.stringify(doc) }); receive = "native"; wakeJoined = true; }
989
+ catch (e) { warnings.push(`joined teams stay poll-only: aw wake register refused the multi-identity registration (${e.message || e})`); }
990
+ } else if (joined.length && !runtimeDeliveryFor({ delivery, runtime })) {
991
+ warnings.push(`joined teams receive by polling: runtime ${runtime || "unknown"} has no native presentation surface for the host wake broker`);
992
+ }
993
+ if (!wakeJoined && meta.wakeJoined) {
994
+ // Back to one identity: a session home keeps its legacy registration, a
995
+ // native home leaves the broker entirely.
996
+ if (delivery === "session") { try { wakeRegister(home, primary); } catch (e) { warnings.push(String(e.message || e)); } }
997
+ else if (!wakeDeregister(home)) warnings.push("aw wake deregister failed; the broker treats a stale registration as inactive on its own");
998
+ }
999
+ const next = { ...meta, ...(runtime ? { runtime } : {}), wakeJoined, joinedTeams: joinedTeamsOf(meta).map((j) => ({ ...j, receive: joined.includes(j) ? receive : "poll" })) };
1000
+ return { meta: next, warnings };
1001
+ }
1002
+
872
1003
  if (event === "launch") {
873
1004
  if (identityMode === "global" || grantRenewMode() === "launch") globalGrantRenew();
874
1005
  let oldMeta = withProviderTeams(JSON.parse(process.env.OATS_META || "{}"));
875
1006
  const joined = joinedTeamsOf(oldMeta);
876
1007
  if (joined.length && process.env.OATS_TEAMS_SOURCE === "live") {
877
1008
  const eligible = new Set(eligibleTeams().map((t) => t.label));
878
- let changed = false;
879
1009
  const warnings = [];
880
1010
  for (const row of joined) if (!eligible.has(row.label)) {
881
- try { oldMeta = leaveJoinedTeam(row.label, oldMeta).meta; changed = true; }
1011
+ try { oldMeta = leaveJoinedTeam(row.label, oldMeta).meta; }
882
1012
  catch (e) { warnings.push(`joined team ${row.label} cleanup failed: ${e.message || e}`); }
883
1013
  }
884
- if (changed) writeProviderTeamsState(oldMeta);
885
- out({ ...(changed ? { meta: oldMeta } : {}), ...retainedLaunchOutput(oldMeta), ...(warnings.length ? { warning: `oats-aweb: ${warnings.join(" | ")}` } : {}) });
1014
+ // Re-register what remains: the runtime may differ from the last session.
1015
+ const synced = syncWakeReceive(oldMeta);
1016
+ oldMeta = synced.meta;
1017
+ warnings.push(...synced.warnings);
1018
+ writeProviderTeamsState(oldMeta);
1019
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta), ...(warnings.length ? { warning: `oats-aweb: ${warnings.join(" | ")}` } : {}) });
1020
+ }
1021
+ if (joined.length) {
1022
+ const synced = syncWakeReceive(oldMeta);
1023
+ oldMeta = synced.meta;
1024
+ writeProviderTeamsState(oldMeta);
1025
+ out({ meta: oldMeta, ...retainedLaunchOutput(oldMeta), warning: ["oats-aweb: teams-unverified — keeping joined team memberships because live eligible teams are unavailable", ...synced.warnings.map((w) => `oats-aweb: ${w}`)].join(" | ") });
886
1026
  }
887
- if (joined.length && process.env.OATS_TEAMS_SOURCE !== "live") out({ ...retainedLaunchOutput(oldMeta), warning: "oats-aweb: teams-unverified — keeping joined team memberships because live eligible teams are unavailable" });
888
1027
  out(retainedLaunchOutput(oldMeta));
889
1028
  } else if (event === "spawn") {
890
1029
  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');
@@ -895,32 +1034,16 @@ if (event === "launch") {
895
1034
  try { joinRows = validateJoinLabels(requestedJoinLabels()); }
896
1035
  catch (e) { fatal(e.message || e); }
897
1036
  let minted; // external identity, once `aw team join` succeeds
898
- const root = awebRoot();
899
- if (!root) {
900
- const candidate = rootSettingCandidate();
901
- fatal(`${awebRootProblem(candidate)}, so no identity could be minted and this instance would have no messaging`);
902
- }
1037
+ let spawnMeta; // with joined teams, once any is accepted
903
1038
  try {
904
1039
  // Team correctness: the config's `team:` block wins (id, then name), else the
905
1040
  // root's active team. ALWAYS pass --team-id explicitly — never inherit whatever
906
1041
  // team happens to be active at mint time — and verify the joined cert matches.
907
1042
  // The instance name IS the discoverable alias (the team roster doubles as the
908
1043
  // cross-machine instance directory).
909
- const resolvedTeam = payloadTeam();
910
- let team = resolvedTeam.team;
911
- const warnings = [];
912
- const unmappedPrimary = !team ? unmappedPrimaryRow() : undefined;
913
- if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
914
- if (unmappedPrimary && team) warnings.push(`oats-aweb: team-unmapped — workspace label ${unmappedPrimary.label} is not mapped; using personal team ${team}`);
915
- if (!team) fatal(`cannot determine target team, so no identity could be minted — ${teamConfigRemedy()}, or activate a team at the aweb root`);
916
- // A bare team name (no namespace) resolves against the root's memberships.
917
- if (!team.includes(":")) {
918
- const teams = JSON.parse(run(["aw", "team", "list", "--json"], root));
919
- const match = teamIdsOf(teams).filter((tid) => String(tid).startsWith(`${team}:`));
920
- if (match.length === 1) team = match[0];
921
- else if (match.length > 1) fatal(`team name "${team}" is ambiguous at ${root}: ${match.join(", ")}, so no identity could be minted — ${teamConfigRemedy()}`);
922
- 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()}`);
923
- }
1044
+ const primary = resolvePrimaryTeam();
1045
+ const { team, root } = primary;
1046
+ const warnings = [...primary.warnings];
924
1047
  // Both of these carry the invite token — one mints it, the other spends it —
925
1048
  // so neither their output nor their diagnostics may reach a log.
926
1049
  const inv = parseSecretJson(run(["aw", "team", "invite", "--team-id", team, "--json"], root, 45000, { secretSafe: true }), "aw team invite");
@@ -987,15 +1110,19 @@ if (event === "launch") {
987
1110
  const deliveryBrief = deliveryMode === "session"
988
1111
  ? ` 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.`
989
1112
  : "";
990
- let meta = { team: joined.team_id, alias, delivery: deliveryMode, identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
1113
+ let meta = { team: joined.team_id, alias, delivery: deliveryMode, personal: { team: joined.team_id, source: primary.source }, ...(process.env.OATS_RUNTIME ? { runtime: process.env.OATS_RUNTIME } : {}), identity: identityMeta({ mode: "local", alias, team: joined.team_id }) };
991
1114
  const joinFloorProblem = joinRows.length ? joinedTeamsAwFloorProblem() : undefined;
992
1115
  if (joinFloorProblem) warnings.push(`oats-aweb: ${joinFloorProblem}`);
993
- else for (const row of joinRows) { const result = mintJoinedTeam(row, meta); meta = result.meta; if (result.warning) warnings.push(`oats-aweb: ${result.warning}`); }
1116
+ else for (const row of joinRows) { const result = mintJoinedTeam(row, meta); meta = result.meta; spawnMeta = meta; writeProviderTeamsState(meta); if (result.warning) warnings.push(`oats-aweb: ${result.warning}`); }
1117
+ if (joinedTeamsOf(meta).length) { const synced = syncWakeReceive(meta); meta = synced.meta; for (const w of synced.warnings) warnings.push(`oats-aweb: ${w}`); }
994
1118
  writeProviderTeamsState(meta);
1119
+ const personalBrief = meta.personal.source === "setting" ? "the team this deployment configured for you" : "your personal team (the messaging root's active team)";
1120
+ const joinedNow = joinedTeamsOf(meta);
1121
+ const joinedBrief = joinedNow.length ? ` Joined teams: ${joinedNow.map((j) => `${j.label} (${j.team}, receive ${j.receive}, send with \`aw --identity-home ${j.identityHome} mail|chat ...\`)`).join("; ")}.` : "";
995
1122
  out({
996
1123
  meta,
997
1124
  env,
998
- brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}.${mismatch}${deliveryBrief} Joined team identities receive by polling in oats.aweb 1.14; run \`oats aweb teams --json\` for identity homes. Use \`aw mail\`/\`aw chat\` for messaging (see the aweb-messaging skill); coordination stays in your deployment's task layer.`,
1125
+ brief: `Comms: you have an aweb identity — alias "${alias}" on team ${joined.team_id}, ${personalBrief}.${mismatch}${deliveryBrief}${joinedBrief} Load the oats-aweb skill before messaging: \`oats aweb teams --json\` shows your teams, \`oats aweb roster\` who you can reach. Coordination stays in your deployment's task layer.`,
999
1126
  ...(launch ? { launch } : {}),
1000
1127
  ...(joined.team_id !== team ? { warning: `oats-aweb: team mismatch — joined ${joined.team_id}, expected ${team}` } : warnings.length ? { warning: warnings.join(" | ") } : channelWarning ? { warning: channelWarning } : {}),
1001
1128
  });
@@ -1003,7 +1130,7 @@ if (event === "launch") {
1003
1130
  // A join may already have created a REMOTE identity before the failure.
1004
1131
  // Hand it back as meta so the kernel's compensation can delete it — losing
1005
1132
  // it here would strand a roster entry no one owns.
1006
- fatal(`identity minting failed: ${e.message || e}`, minted);
1133
+ fatal(`identity minting failed: ${e.message || e}`, minted && joinedTeamsOf(spawnMeta).length ? { ...minted, joinedTeams: joinedTeamsOf(spawnMeta) } : minted);
1007
1134
  }
1008
1135
  } else if (event === "retire") {
1009
1136
  let meta = withProviderTeams(JSON.parse(process.env.OATS_META || "{}"));
@@ -1017,6 +1144,10 @@ if (event === "launch") {
1017
1144
  try { meta = leaveJoinedTeam(joined.label, meta).meta; }
1018
1145
  catch (e) { retireWarnings.push(`joined team ${joined.label} cleanup failed: ${e.message || e}`); }
1019
1146
  }
1147
+ // A native (channel/pi) home registered with the broker only for its joined
1148
+ // teams; a session home was deregistered above.
1149
+ if (meta.wakeJoined && meta.delivery !== "session") { if (!wakeDeregister(home)) retireWarnings.push("aw wake deregister failed; the broker treats a retired home as inactive on its own"); }
1150
+ meta = { ...meta, wakeJoined: false };
1020
1151
  writeProviderTeamsState(meta);
1021
1152
  if (meta.retained) {
1022
1153
  if (meta.lock) { try { rmSync(meta.lock, { force: true }); } catch { /* the lock may already be gone */ } }
@@ -1065,15 +1196,35 @@ if (event === "launch") {
1065
1196
  }
1066
1197
  } else if (["teams", "join", "leave"].includes(event)) {
1067
1198
  try { runTeamsCommand(event); process.exit(0); }
1068
- catch (e) { console.error(e.code ? `${e.code}: ${e.message}` : `oats aweb ${event}: ${e.message || e}`); process.exit(1); }
1199
+ catch (e) {
1200
+ console.error(e.code ? `${e.code}: ${e.message}` : `oats aweb ${event}: ${e.message || e}`);
1201
+ if (operation) operationFail(e.code, e.message || String(e), e.partial ? { ...e.partial, joined: teamsDocument(readCapabilityMeta()).joined } : undefined);
1202
+ process.exit(1);
1203
+ }
1069
1204
  } else if (event === "roster") {
1070
1205
  // Cross-machine directory: every OATS-spawned instance joins the team with
1071
1206
  // alias = instance name, so the team's member roster lists live instances
1072
1207
  // wherever they run (plus human members). Local liveness comes from
1073
1208
  // `oats status` in the deployment; this is the network view.
1074
- const root = awebRoot();
1075
- if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate())}`); process.exit(1); }
1076
- const team = process.env.OATS_TEAM_ID || JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
1209
+ // Default: this instance's personal team, listed from the root that minted
1210
+ // it. `--label <label>` lists an eligible (joined or not) workspace team from
1211
+ // the host root that holds it.
1212
+ const argv = process.argv.slice(3);
1213
+ const labelAt = argv.indexOf("--label");
1214
+ const label = labelAt >= 0 ? argv[labelAt + 1] : (argv.find((a) => a.startsWith("--label=")) || "").slice("--label=".length) || undefined;
1215
+ const meta = readCapabilityMeta();
1216
+ let team, root;
1217
+ if (label) {
1218
+ const row = eligibleTeams().find((t) => t.label === label);
1219
+ if (!row) { console.error(`E_TEAM_NOT_ELIGIBLE: ${label} is not an eligible team label for this instance (eligible: ${eligibleTeams().map((t) => t.label).join(", ") || "(none)"})`); process.exit(1); }
1220
+ team = row.team; root = awebRootForTeam(team);
1221
+ if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate(team))}, so the roster of ${label} cannot be read from this host`); process.exit(1); }
1222
+ } else {
1223
+ team = meta.personal?.team || meta.identity?.team || meta.team || process.env.OATS_TEAM_ID;
1224
+ root = awebRoot();
1225
+ if (!root) { console.error(`oats aweb roster: ${awebRootProblem(rootSettingCandidate())}`); process.exit(1); }
1226
+ if (!team) team = JSON.parse(run(["aw", "team", "list", "--json"], root)).active_team;
1227
+ }
1077
1228
  if (!team) { console.error(`oats aweb roster: cannot determine team (${teamConfigRemedy()}, or activate a team at the aweb root)`); process.exit(1); }
1078
1229
  const teamFlag = team.includes(":") ? ["--team-id", team] : ["--team", team];
1079
1230
  const r = JSON.parse(run(["aw", "id", "team", "members", ...teamFlag, "--json"], root, 60000));
@@ -1082,7 +1233,7 @@ if (event === "launch") {
1082
1233
  const members = r.members || [];
1083
1234
  if (!members.length) console.log(" (no member certificates visible from this workspace)");
1084
1235
  for (const m of members) console.log(` ${m.alias || m.name || m.did || JSON.stringify(m)}`);
1085
- console.log("\nAliases minted by OATS are instance names; message one with `aw mail send --to <alias> --subject \"...\" --body \"...\"`.");
1236
+ console.log(`\nAliases minted by OATS are instance names; message one with \`aw mail send --to <alias> --subject "..." --body-file <file>\`${label ? ` as this team: \`aw --identity-home <identityHome> mail send ...\` (identityHome from \`oats aweb teams --json\`)` : ""}.`);
1086
1237
  process.exit(0);
1087
1238
  } else if (event === "setup") {
1088
1239
  // Guided onboarding — idempotent, prints what it finds and can run one
@@ -1,82 +1,47 @@
1
1
  ## Messaging: aweb
2
2
 
3
- Your messaging layer is **aweb**. You have (or will be minted) a team-scoped
4
- aweb identity — alias = your instance name — on your deployment's team (see
5
- `instance.json` / your TASK.md briefing for the team).
3
+ Your messaging layer is **aweb**. Your identity's alias is your instance name,
4
+ and it lives in the personal team of the person you work for (the `Comms:`
5
+ line of your TASK.md names it). Wider workspace teams are joined explicitly,
6
+ each with its own identity home.
6
7
 
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. Your grant home is attached to the resident's custody service; if a
12
- message you sent shows unverified at the receiver, the attachment is missing:
13
- report it, do not retry. If `aw mail` or `aw chat` reports a terminal grant condition —
14
- `grant_expired`, `grant_revoked`, `grant_subject_inactive`,
15
- `grant_issuer_revoked`, or `grant_freshness_unavailable` — report the exact
16
- condition to your coordinator/human and stop using messaging. The host restarts
17
- you with a fresh grant when renewal is available.
8
+ **Load the `oats-aweb` skill before your first `aw mail`/`aw chat` of a session,
9
+ whenever an aweb wake or channel event arrives, and whenever messaging or a
10
+ team command looks wrong.** It covers your teams, the roster, sending and
11
+ replying, how wakes work, etiquette and troubleshooting. Do not work from
12
+ memory; if a flag looks wrong run `aw <command> --help`.
18
13
 
19
- **Load the skills at the right moments — do not work from memory:**
20
-
21
- - **Before your first `aw mail`/`aw chat` of a session**, load the
22
- **aweb-messaging** skill. It is the
23
- playbook for sending, replying, and chat etiquette.
24
- - **When an aweb channel event awakens you**, read the injected event
25
- metadata first, then load the **aweb-messaging** skill ("Read the event
26
- first" section) before responding — continue the existing conversation,
27
- never
28
- start a new thread when a `message_id`/`conversation_id` is provided.
29
- - For team/roster/certificate questions, load **aweb-team-membership**; for
30
- identity/key questions, load **aweb-identity**.
31
- - If a command errors or a flag looks wrong, re-read the skill or run
32
- `aw <cmd> --help` — never invent flags.
33
-
34
- Quick crib (the skill has the full craft; run from your instance home):
14
+ Quick crib (run from your instance home, never from `./work`):
35
15
 
36
16
  ```bash
37
- aw mail inbox # UNREAD mail only
38
- aw mail inbox --show-all # full history (read mail is not lost)
39
- aw mail send --to <alias> --subject "..." --body "..." # recipient needs --to
40
- aw mail send --to <alias> --subject "..." --body-file <f> # markdown/backticks
41
- aw mail reply <message-id> --body "..." # reply on an existing thread
42
- aw chat send --to <alias> --body "..." # synchronous back-and-forth
17
+ oats aweb teams --json # personal, eligible, joined teams
18
+ oats aweb roster # who you can reach
19
+ aw mail inbox # UNREAD mail only (--show-all: history)
20
+ aw mail send --to <alias> --subject "..." --body-file <f> # recipient needs --to
21
+ aw mail reply <message-id> --body-file <f> # stay in the thread
22
+ aw chat send-and-wait <alias> --body-file <f> --start-conversation # blocking question
23
+ aw chat pending # chats waiting on you
24
+ aw --identity-home <identityHome> mail|chat ... # act as a joined team
43
25
  ```
44
26
 
45
- Flags are exactly these — there is no positional recipient and no
46
- `--reply-to`; when unsure run `aw mail send --help`, don't guess. For any
47
- body longer than a sentence (or containing quotes/backticks/newlines),
48
- write it to a temp file and use `--body-file` — inline `--body` shell
49
- escaping is a recurring failure.
50
-
51
- Aliases are instance names (e.g. `dev-coordinator-1`). Discovery:
52
- `oats status` (in the deployment) lists this machine's live instances; `oats aweb roster`
53
- lists the aweb team across machines.
54
-
55
- **Joined teams.** Your default identity is the personal team — in 1.14.1 the root's default team as a stand-in until per-workspace personal teams exist. Workspace labels, including the primary label, are wider teams only when explicitly joined. If `oats aweb teams --json`
56
- shows joined wider teams, each joined entry has an `identityHome`. Send or reply as
57
- that team with exactly `aw --identity-home <identityHome> mail|chat ...`. In
58
- 1.14.1 joined teams receive by polling: check `aw --identity-home <identityHome>
59
- mail inbox` and `aw --identity-home <identityHome> chat pending` at task
60
- boundaries when you are working through that team. The native channel/wake path
61
- listens to the primary identity only.
62
-
63
- **Notification delivery.** Your instance briefing (TASK.md, the Comms line)
64
- says how messages reach you. If it carries "Notification delivery: external",
65
- the native channel is NOT running in this session and, until the host wake
66
- broker registers you, nothing wakes you: check `aw mail inbox` and
67
- `aw chat pending` at every task boundary. Otherwise the rule below applies.
68
-
69
- **Never sleep, poll, or busy-wait for another agent's reply.** Send your
70
- message, finish your turn, and go idle when a delivery channel is configured
71
- (you saw `✓ aweb connected` at startup). Native Codex has no aweb channel:
72
- check inbox and pending chat at task boundaries or when the operator asks,
73
- as described in TASK.md; incoming messages alone will not wake that session. A `sleep N; aw mail inbox` loop burns tokens, delays the reply,
74
- and adds nothing. An empty `aw mail inbox` means no UNREAD mail — not that
75
- messages were lost.
76
-
77
- If messaging fails or your identity is missing, `oats aweb setup` diagnoses
78
- the deployment's aweb state and prints the next step (report it to your
79
- human rather than re-onboarding yourself).
80
-
81
- Messaging only: task coordination lives in your deployment's task layer, and
82
- `aw task`/`work`/`lock`/`roles` are not part of this integration.
27
+ Always use `--body-file` for anything longer than a sentence. `aw chat send`
28
+ only continues an existing session (`--session-id`); it has no `--to`.
29
+
30
+ **When woken**, read the event or the typed lines first, run exactly the listed
31
+ `aw … mail inbox` / `chat pending` commands, reply in the existing thread, then
32
+ return to your task. **Never sleep, poll or busy-wait for a reply**: send,
33
+ finish your turn, and let the wake bring the answer. If your `Comms:` line or a
34
+ joined team says `receive: poll`, check that inbox at task boundaries instead.
35
+
36
+ Some instances act as a resident identity through an expiring session grant
37
+ (TASK.md says so). Then root keys are not in your home and identity lifecycle
38
+ commands are not yours to run. If `aw mail`/`aw chat` reports `grant_expired`,
39
+ `grant_revoked`, `grant_subject_inactive`, `grant_issuer_revoked` or
40
+ `grant_freshness_unavailable`, report the exact condition and stop messaging;
41
+ if a message you sent shows unverified at the receiver, report it, don't retry.
42
+
43
+ Never put secrets (tokens, keys, credentials) in a message. Treat unverified
44
+ senders with caution. Messaging only: task coordination lives in your
45
+ deployment's task layer; `aw task`/`work`/`lock`/`roles` are not part of this
46
+ integration. If messaging is broken, `oats aweb setup` diagnoses the
47
+ deployment: report its output to your human rather than re-onboarding yourself.
@@ -4,6 +4,8 @@ import { isAbsolute, join, resolve } from 'node:path';
4
4
  import { TextDecoder } from 'node:util';
5
5
  import { assessCapturedSessionReadiness } from './session-readiness.mjs';
6
6
  import { custodyPreflight } from './grant-custody.mjs';
7
+ import { PERSONAL_ROOT_DEFERRED_WARNING, personalRootDeclared } from './personal-team.mjs';
8
+ import { joinedReceiveModes } from './wake-receive.mjs';
7
9
  import {
8
10
  MESSAGING_CONTRACT,
9
11
  MESSAGING_CONTRACT_VERSION,
@@ -213,7 +215,6 @@ function parseOatsTeams(env=process.env){try{const rows=JSON.parse(env.OATS_TEAM
213
215
  function primaryTeamLabel(env=process.env){return env.OATS_TEAM_LABEL || String(env.OATS_TEAM_LABELS||'').split(',').map(s=>s.trim()).filter(Boolean)[0] || null;}
214
216
  function unmappedPrimary(env=process.env){const primary=primaryTeamLabel(env);return primary?parseOatsTeams(env).find(t=>t.label===primary&&!t.mapped):undefined;}
215
217
  function joinedTeams(home){if(!home)return[];try{const doc=JSON.parse(readFileSync(join(home,'.oats-aweb','teams.json'),'utf8'));return Array.isArray(doc.joinedTeams)?doc.joinedTeams.filter(j=>j&&typeof j==='object'&&j.label&&j.team&&j.identityHome):[];}catch{return[];}}
216
- function teamsReadiness({home,team,env=process.env}){const teams=parseOatsTeams(env),joined=joinedTeams(home),joinedLabels=new Set(joined.map(j=>j.label));return{personal:{team:team||null},primary:primaryTeamLabel(env),eligible:teams.filter(t=>t.mapped&&t.team).map(t=>({label:t.label,team:t.team,joined:joinedLabels.has(t.label)})),joined:joined.map(j=>({label:j.label,team:j.team,identityHome:j.identityHome,receive:j.receive||'poll',since:j.since})),unmapped:teams.filter(t=>!t.mapped).map(t=>t.label),at:new Date().toISOString()};}
217
218
  function readinessDetails(settings,{deployment,env=process.env}={}) {
218
219
  if(classicEnv(env)) return {team:undefined,candidate:null,warnings:[],result:{status:'needs-configuration',problems:[{code:'needs-configuration',message:CLASSIC_REFUSAL}]}};
219
220
  const initialTeam=typeof settings.team==='string' && settings.team.trim()?settings.team.trim():undefined;
@@ -221,11 +222,13 @@ function readinessDetails(settings,{deployment,env=process.env}={}) {
221
222
  if(!candidate.root || !isAbsolute(candidate.root) || !existsSync(join(resolve(candidate.root),'.aw'))) problems.push({code:'needs-configuration',message:`no messaging root at ${candidate.root?resolve(candidate.root):process.cwd()}: run oats aweb setup there or set ${candidate.key}`});
222
223
  const unmapped=unmappedPrimary(env);if(unmapped&&team)warnings.push({code:'team-unmapped',message:`workspace label ${unmapped.label} is not mapped; using personal team ${team}`});
223
224
  if(!team) problems.push({code:'needs-configuration',message:'no team: set settings.oats.aweb.team or keep an active team at the aweb root'});
225
+ if(personalRootDeclared(settings)) warnings.push({code:'personal-root-deferred',message:PERSONAL_ROOT_DEFERRED_WARNING});
224
226
  return {team,candidate,warnings,result:checkProblems(problems) || {status:'ready',problems:[]}};
225
227
  }
226
228
  function readinessFromSettings(settings,options) {return readinessDetails(settings,options).result;}
227
229
  function runAw(argv,cwd,{unsetEnv=[],timeout=60000}={}) {
228
- const env={...process.env};for(const name of unsetEnv) delete env[name];
230
+ // An inherited AWEB_IDENTITY_HOME is the caller's identity, never this check's.
231
+ const env={...process.env};delete env.AWEB_IDENTITY_HOME;for(const name of unsetEnv) delete env[name];
229
232
  try {return execFileSync(argv[0],argv.slice(1),{cwd,env,encoding:'utf8',stdio:['ignore','pipe','pipe'],timeout}).trim();}
230
233
  catch(e) {throw new Error(`${argv.slice(0,3).join(' ')} failed${e.status===undefined?'':` (exit ${e.status})`}`);}
231
234
  }
@@ -264,8 +267,16 @@ function workspaceReadinessPhase(req) {
264
267
  catch(e) {problems.push({code:'custody',message:e.message});}
265
268
  }
266
269
  }
267
- const teams=process.env.OATS_TEAMS?teamsReadiness({home:ctx.home,team:details.team,env:process.env}):undefined;
268
- for(const joined of teams?.joined||[]) if(joined.receive==='poll') warnings.push({code:'joined-team-poll-only',message:`joined team ${joined.label} receives by polling in oats.aweb 1.14; check aw --identity-home ${joined.identityHome} mail inbox/chat pending`});
270
+ const joined=joinedTeams(ctx.home);
271
+ if(joined.length) {
272
+ let status;try{status=JSON.parse(runAw(['aw','wake','status','--json'],ctx.home,{timeout:10000}));}catch{status=undefined;}
273
+ const why={'home-not-registered':'this home is not registered with the host wake broker','not-registered-with-broker':'its identity home is not registered with the host wake broker','wake-daemon-not-running':'the host wake daemon is not running','stream-not-admitted':'the host wake broker has not admitted its stream'};
274
+ for(const mode of joinedReceiveModes(status,{home:ctx.home,joined})) {
275
+ const row=joined.find(j=>j.label===mode.label);
276
+ if(mode.receive==='native') warnings.push({code:'joined-team-receive',message:`joined team ${mode.label} receives native through the host wake broker (stream ${mode.phase})`});
277
+ else warnings.push({code:'joined-team-poll-only',message:`joined team ${mode.label} receives by polling: ${status?why[mode.reason]||mode.reason:'aw wake status is unavailable'}${mode.detail?` (${mode.detail})`:''}; check aw --identity-home ${row.identityHome} mail inbox and chat pending at task boundaries`});
278
+ }
279
+ }
269
280
  const wake=String(req.settings.delivery||'channel')==='session'?wakeReadiness(ctx.home,{reliedOn:true}):{problems:[],warnings:[]};
270
281
  problems.push(...wake.problems);warnings.push(...wake.warnings);
271
282
  const result=checkProblems(problems) || {status:'ready',problems:[]};
@@ -0,0 +1,19 @@
1
+ // Per-workspace personal team: deferred to oats.aweb 1.16.
2
+ //
3
+ // A host's `aw auth` login is one file per OS user, and `aw auth status` does not
4
+ // name the account, so enrolling a workspace's personal team from that login could
5
+ // mint it (and every instance) into whichever account logged in last. 1.16 brings
6
+ // a per-deployment credential location and an expected owner. Until then oats.aweb
7
+ // makes no `aw auth` or `aw team ensure` call on any path, the primary team
8
+ // resolves as in 1.14.2 (settings.team, else the root's active team), and a
9
+ // declared settings.oats.aweb.roots.personal is ignored with a readiness warning.
10
+
11
+ export const PERSONAL_ROOT_KEY = 'settings.oats.aweb.roots.personal';
12
+ export const PERSONAL_ROOT_DEFERRED_WARNING = `${PERSONAL_ROOT_KEY} is ignored: per-workspace personal-team enrollment arrives in oats.aweb 1.16; the primary team is settings.oats.aweb.team, else the aweb root's active team`;
13
+
14
+ const obj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
15
+
16
+ /** True when the host declared roots.personal (any value): 1.15 ignores it and says so. */
17
+ export function personalRootDeclared(settings) {
18
+ return obj(settings?.roots) && Object.hasOwn(settings.roots, 'personal');
19
+ }