@awebai/oats 0.35.4 → 0.36.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.
package/bin/oats.mjs CHANGED
@@ -1850,7 +1850,7 @@ async function statusDrift(data) {
1850
1850
  catch { /* not a workspace soul (classic, or an unreadable stamp) */ }
1851
1851
  }
1852
1852
  const anything = data.some((a) => (a.instances || []).some((i) => hasModules(i) || hasSoul(i)));
1853
- if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null };
1853
+ if (!anything) return { drift: new Map(), soul: new Map(), souls, unreachable: null, local: ctx.local, discovery: null };
1854
1854
  const deploymentDir = dirname(ctx.path);
1855
1855
  let lock = null;
1856
1856
  try { lock = readLockIfPresent(deploymentDir); } catch { lock = null; }
@@ -1860,7 +1860,7 @@ async function statusDrift(data) {
1860
1860
  try { const { discoverOrStandalone } = await import("../lib/instance-resolution.mjs"); discovery = await discoverOrStandalone(ctx.local, { lock, remoteOptions: remoteOptionsFromEnv() }); }
1861
1861
  catch (e) {
1862
1862
  const reason = e?.details?.reason ? `${e.code}: ${e.details.reason}` : (e?.code || e?.message || "unknown");
1863
- return { drift: new Map(), soul: new Map(), souls, unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) } };
1863
+ return { drift: new Map(), soul: new Map(), souls, unreachable: { code: e?.code ?? null, reason, message: e?.message ?? String(e) }, local: ctx.local, discovery: null };
1864
1864
  }
1865
1865
  const { driftOf, soulDriftOf } = await import("../lib/materialize.mjs");
1866
1866
  const drift = new Map();
@@ -1879,7 +1879,67 @@ async function statusDrift(data) {
1879
1879
  const member = memberRowByKey(discovery.members, stamp.repoKey);
1880
1880
  if (member && typeof member.commit === "string" && (member.confirmed || (discovery.standalone === true && member.key === discovery.key))) stamp.current = member.commit;
1881
1881
  }
1882
- return { drift, soul, souls, unreachable: null };
1882
+ return { drift, soul, souls, unreachable: null, local: ctx.local, discovery };
1883
+ }
1884
+ /** The deployment's workspace identity in `oats status --json` (feature workspace-identity), read
1885
+ * OFFLINE. `key` is the workspace HOST's canonical repo key (parseRepoRef(...).key) and `ref` the
1886
+ * reference as oats-local.yaml writes it. The host is the one this run observed, else the one this
1887
+ * machine's parsed cache knows: `ref` itself when the cache holds its workspace file, or the host its
1888
+ * cached oats-membership.yaml names when `ref` is a member ("workspace"). A member whose host is not
1889
+ * known keys as itself ("member"); a ref nothing is known of is taken as the host, as the schema
1890
+ * defines `workspace:` ("workspace"). The team model resolves as teamModel resolves it (the default
1891
+ * label is local; a committed team wins a collision) over the shared teams of, in order, the workspace
1892
+ * file this run observed (`teamsFrom: "observed"`), the cached file ("cache": no git process), or none
1893
+ * ("local"). `standalone` is the CONFIGURED standalone view only (oats-local.yaml `standalone:`): it
1894
+ * reads no workspace file, so its local teams are the whole team model. A run that fell back to the
1895
+ * standalone view (the host unreadable) is not: its team model is the workspace's, and its
1896
+ * discovery is the member's, never the host's key. */
1897
+ function workspaceIdentity(local, discovery) {
1898
+ const ref = local.workspace;
1899
+ const standalone = typeof local.standalone === "string" && local.standalone !== "";
1900
+ const observed = discovery && discovery.standalone !== true && discovery.workspace ? discovery : null;
1901
+ const cached = observed ? null : cachedWorkspace(ref);
1902
+ const keyOf = (r) => { try { return remoteModule.parseRepoRef(r).key; } catch { return null; } };
1903
+ let key, keyFrom;
1904
+ if (observed) [key, keyFrom] = [observed.key, "workspace"];
1905
+ else if (cached && cached.host === null) [key, keyFrom] = [keyOf(ref), "member"];
1906
+ else [key, keyFrom] = [keyOf(cached?.host ?? ref), "workspace"];
1907
+ if (key === null) keyFrom = null; // a reference parseRepoRef refuses: unusable, never matched
1908
+ let shared = null, teamsFrom = "local";
1909
+ if (!standalone) {
1910
+ if (observed) [shared, teamsFrom] = [observed.workspace, "observed"];
1911
+ else if (cached?.file) [shared, teamsFrom] = [cached.file, "cache"];
1912
+ }
1913
+ const model = teamModel(shared, local);
1914
+ const labels = [...model.labels.keys()].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1915
+ return {
1916
+ key, ref, keyFrom, standalone,
1917
+ defaultTeam: model.defaultTeam === null ? null : { label: model.defaultTeam, team: model.labels.get(model.defaultTeam)?.team ?? null },
1918
+ teams: Object.fromEntries(labels.map((l) => [l, model.labels.get(l).team])),
1919
+ teamsFrom,
1920
+ };
1921
+ }
1922
+ /** What this machine's parsed cache knows of the workspace `ref` names (the values observeWorkspace and
1923
+ * confirmMembership stored, each at its repo's last observed commit): { host, file } — `host` the ref
1924
+ * of the workspace host, `file` its workspace file or null — when `ref` is the host (its workspace file
1925
+ * is cached) or a member whose cached oats-membership.yaml names it (discoverOrStandalone follows the
1926
+ * same backlink). A member's backlink is cached whether its own workspace slot was observed (missing)
1927
+ * or never was (the host's discovery confirmed it). { host: null, file: null } for a ref cached as
1928
+ * having no workspace file and no backlink; null when nothing is known. */
1929
+ function cachedWorkspace(ref) {
1930
+ const options = remoteOptionsFromEnv();
1931
+ const cached = (r, item) => {
1932
+ const commit = remoteModule.lastObservedCommit(r, options);
1933
+ const value = commit ? remoteModule.peekAtCommit(r, commit, item, options) : undefined;
1934
+ return value && typeof value === "object" ? value : null;
1935
+ };
1936
+ const fileOf = (read) => (read && !read.missing && !read.problems && read.value && typeof read.value === "object" ? read.value : null);
1937
+ const read = cached(ref, "workspace");
1938
+ if (read && !read.missing) return { host: ref, file: fileOf(read) };
1939
+ const membership = cached(ref, "membership");
1940
+ const host = membership?.kind === "ok" && typeof membership.value?.workspace === "string" ? membership.value.workspace : null;
1941
+ if (host !== null) return { host, file: fileOf(cached(host, "workspace")) };
1942
+ return read ? { host: null, file: null } : null;
1883
1943
  }
1884
1944
  /** One `modules:` line per module. */
1885
1945
  function driftLine(row) {
@@ -1948,7 +2008,7 @@ async function status() {
1948
2008
  }
1949
2009
  }
1950
2010
  const observation = maxAgeGiven === null ? {} : { observation: observationBlock() };
1951
- console.log(JSON.stringify({ root, agents: data, ...observation, ...(ws ? { workspace: ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true } } : {}), ...(problems.length ? { problems } : {}), ...envelopeWarnings() }, null, 2)); return;
2011
+ console.log(JSON.stringify({ root, agents: data, ...observation, ...(ws ? { workspace: { ...(ws.unreachable ? { reachable: false, ...ws.unreachable } : { reachable: true }), ...workspaceIdentity(ws.local, ws.discovery) } } : {}), ...(problems.length ? { problems } : {}), ...envelopeWarnings() }, null, 2)); return;
1952
2012
  }
1953
2013
  console.log(`oats status — agents root ${shortPath(root)}\n`);
1954
2014
  if (ws?.unreachable) console.log(` workspace: unreachable (${ws.unreachable.reason}) — drift unknown\n`);
@@ -3059,7 +3119,7 @@ function versionCmd() {
3059
3119
  // Phase B: `instance-modules` and `spawn-provider-payload` are advertised only once spawn
3060
3120
  // runs on resolve/materialize (contract §6); a feature the binary does not implement is
3061
3121
  // never listed.
3062
- console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3122
+ console.log(JSON.stringify({ schemaVersion: 1, name: "@awebai/oats", version: OATS_VERSION, desktopApi: 1, harnesses: ["pi", "claude", "codex"], sessionBackends: ["tmux"], launchOptions: ["yolo"], remote: ["spawn", "retire", "status", "session", "session-start", "session-restart", "launch-config", "roster", "harvest", "schedule", "session-upload", "operations", "readiness", "instance-events", "instance-git", "lifecycle-plans"], features: ["retire-home", "session-start", "session-restart", "launch-config", "schedule", "session-upload", "operations", "instance-git", "instance-git-remote", "souls-declarations", "lifecycle-plans", "retire-retention", "readiness", "spawn-preview", "instance-events", "instance-events-2", "schedule-history", "schedule-read-2", "spawn-preview-2", "spawn-idempotency", "spawn-idempotency-2", "spawn-apply-2", "workspace-v2", "instance-modules", "spawn-provider-payload", "served-identity", "packages-no-approval", "spawn-name", "settings-origins", "team-model-2", "settings-declared", "capabilities-private", "layers-from", "harness", "package-souls", "triggers", "automations", "desktop-facts", "launch-preference", "preview-composed-from", "observe-max-age", "spawn-preview-max-age", "launch-config-default", "capability-show", "capture-file", "workspace-identity"], automationsApi: A.AUTOMATIONS_API, workspaceApi: 2, instanceGitApi: 1, spawnApplyApi: 1, soulsApi: 2, lifecycleApi: 1, readinessApi: 2, spawnPreviewApi: 2, eventsApi: 2, scheduleHistoryApi: 3, scheduleApi: SCHEDULE_API, operationsApi: 2, capabilityShowApi: 1 }));
3063
3123
  return;
3064
3124
  }
3065
3125
  console.log(`@awebai/oats ${OATS_VERSION} (desktop API v1)`);
@@ -38,7 +38,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
38
38
  "instance-events-2","schedule-history","schedule-read-2","spawn-preview-2","spawn-idempotency","spawn-idempotency-2","spawn-apply-2",
39
39
  "workspace-v2","instance-modules","spawn-provider-payload","served-identity","packages-no-approval","spawn-name","settings-origins",
40
40
  "team-model-2","settings-declared","capabilities-private","layers-from","harness","package-souls","triggers","automations","desktop-facts","launch-preference",
41
- "preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show","capture-file"],
41
+ "preview-composed-from","observe-max-age","spawn-preview-max-age","capability-show","capture-file","workspace-identity"],
42
42
  "automationsApi":1,"workspaceApi":2,"instanceGitApi":1,"spawnApplyApi":1,"soulsApi":2,"lifecycleApi":1,
43
43
  "readinessApi":2,"spawnPreviewApi":2,"eventsApi":2,"scheduleHistoryApi":3,"scheduleApi":2,"operationsApi":2,
44
44
  "capabilityShowApi":1}
@@ -100,6 +100,7 @@ canonical (`github.com/<org>/<repo>`, or `local/<abs-path>`). Examples use
100
100
  | `spawn-preview-max-age` | `--max-age <s>` on `spawn --preview` and its `observation` block ([Observation reuse](#observation-reuse-feature-observe-max-age-oats-0311), [The preview](#the-preview)) | |
101
101
  | `capability-show` | `oats capabilities show <name>` and its `--file` form, OATS 0.34.0 ([`oats capabilities show`](#oats-capabilities-show)) | `capabilityShowApi: 1` |
102
102
  | `capture-file` | `oats capture --file <path> --format cc\|pi\|codex --home <instance home> [--json]`: one session file captured as `--home` capture would, with a receipt bound to its bytes, OATS 0.35.0 (the capture USAGE and packages/record/README.md) | |
103
+ | `workspace-identity` | the deployment's workspace identity on `oats status --json` `workspace` (`key`, `ref`, `keyFrom`, `standalone`, `defaultTeam`, `teams`, `teamsFrom`) and each `oats server roster --json` group's relayed `workspace`, OATS 0.36.0 ([Workspace identity](#workspace-identity-feature-workspace-identity-oats-0360)) | |
103
104
 
104
105
  Payload-only integers, never in the probe: `onboardApi: 2`, `syncApi: 1`,
105
106
  `workspaceStatusApi: 1`, `capabilitiesApi: 1`, the `oats souls` document's
@@ -344,7 +345,7 @@ An instance subject, abridged:
344
345
  | Key | Meaning |
345
346
  |---|---|
346
347
  | `subject` | `{kind: "instance", instance, home, soul}` or `{kind: "soul", soul, repoKey, commit}` |
347
- | `workspace` | `{key, name, deployment, commit, standalone}`; for a home, `name` is the recorded name (`null` if the spawn predates it) |
348
+ | `workspace` | `{key, name, deployment, commit, standalone}`; for a home, `name` is the recorded name (`null` if the spawn predates it). `standalone` is the view the subject resolves in (a fallback for an unreadable host included), unlike `oats status`'s configured-only [`standalone`](#workspace-identity-feature-workspace-identity-oats-0360) |
348
349
  | `souls` | exactly the subject's soul |
349
350
  | `layers` | `{knowledge, messaging, tasks}`, each `{id, from}` |
350
351
  | `capabilities`, `capabilitiesOff` | the resolved modules (by id) and the ones the soul turned off |
@@ -1679,7 +1680,8 @@ Not an envelope: `{root, agents, observation?, workspace?, problems?, warnings?}
1679
1680
  "modules":[{"name":"oats.okf","from":{"kind":"package","package":"oats.okf","version":"2.1.3","commit":"ab897841…","integrity":"sha256-bada35…","repoKey":"github.com/awebai/oats-okf"},
1680
1681
  "commit":"ab897841…","current":{"commit":"ab897841…","version":"2.1.3"},"status":"current"}],
1681
1682
  "soul":{"repoKey":"github.com/nw/agents","commit":"66566512…","current":"66566512…","status":"current"}}]}],
1682
- "workspace":{"reachable":true}}
1683
+ "workspace":{"reachable":true,"key":"github.com/nw/agents","ref":"git:github.com/nw/agents","keyFrom":"workspace","standalone":false,"defaultTeam":{"label":"eng","team":"eng:nw.aweb.ai"},
1684
+ "teams":{"eng":"eng:nw.aweb.ai","mine":"mine:ana.aweb.ai","ops":null},"teamsFrom":"observed"}}
1683
1685
  ```
1684
1686
 
1685
1687
  - **Agent rows**: the soul's recorded definition plus `dir` and `instances`,
@@ -1712,11 +1714,68 @@ Not an envelope: `{root, agents, observation?, workspace?, problems?, warnings?}
1712
1714
  reason?}`; a package soul adds `package`, `version`, `currentVersion`
1713
1715
  (`missing` reasons: `package-absent`, `soul-absent`).
1714
1716
  - **`workspace`**: `{reachable: true}`, or `{reachable: false, code, reason,
1715
- message}` (modules then stay the recorded map). Absent without
1716
- `oats-local.yaml`.
1717
+ message}` (modules then stay the recorded map), plus the deployment's
1718
+ [workspace identity](#workspace-identity-feature-workspace-identity-oats-0360)
1719
+ either way. Absent without `oats-local.yaml`.
1717
1720
  - `problems`: the legacy-home rows ([dispatch errors](#dispatch-errors)).
1718
1721
  `warnings`: envelope warnings. `--team` is `E_BAD_ARGS` (an envelope).
1719
1722
 
1723
+ <a id="workspace-identity-feature-workspace-identity-oats-0360"></a>
1724
+ **Workspace identity** (feature `workspace-identity`, OATS 0.36.0). The
1725
+ `workspace` object says which workspace and teams this deployment is, read
1726
+ offline with no network, so it is there whether `reachable` is `true` or
1727
+ `false`:
1728
+
1729
+ | Key | Meaning |
1730
+ |---|---|
1731
+ | `key` | the canonical repo key of the workspace HOST (`parseRepoRef(…).key`: every spelling of one repository gives one key, e.g. `git:github.com/nw/agents`, `https://github.com/nw/agents.git` and `git@github.com:nw/agents.git` all give `github.com/nw/agents`). When `oats-local.yaml` names a member in place of its host, it is the host's key once the member's backlink is known. `null` when `parseRepoRef` refuses the reference |
1732
+ | `ref` | the reference exactly as `oats-local.yaml` writes it (`workspace:`), for display only |
1733
+ | `keyFrom` | `"workspace"`: `key` is the host's, because this run observed it, or the cache holds the ref's workspace file, or a member's cached backlink names the host. With nothing observed or cached, the ref is taken as the host, as the schema defines `workspace:`. `"member"`: the ref names a member whose host is not known yet, so `key` is the member's own. `null` with a `null` key |
1734
+ | `standalone` | `true` only when `oats-local.yaml` sets `standalone:` (the configured standalone view, whose local teams are its whole team model). A run that fell back to the standalone view because the host is unreadable is `false`: its teams are the workspace's, read through the sources below. Unlike `workspace.standalone` on [inspect](#oats-inspect), which is the view a home runs in |
1735
+ | `defaultTeam` | `{label, team}`: the label is `oats-local.yaml`'s `defaultTeam`, `team` its provider id from `teams` (`null` when that map gives none). `null` when `oats-local.yaml` names no default team |
1736
+ | `teams` | `{<label>: <provider team id> \| null}`: every team label the deployment maps, local and shared, by label; a label in both is the committed (shared) one, as [`oats teams`](#oats-teams) resolves it |
1737
+ | `teamsFrom` | where the shared teams came from: `"observed"`, the workspace file this run read; `"cache"`, this machine's cached copy at the host commit it last observed (no git process, no network; when `key` names a member, the host its cached `oats-membership.yaml` names); `"local"`, none: `teams` holds the local teams only |
1738
+
1739
+ A configured standalone deployment reads no workspace file, so it is always
1740
+ `teamsFrom: "local"`, with its local teams only (as spawn resolves them there).
1741
+ `oats status` only reads the workspace when an instance records modules or a
1742
+ workspace soul, so an empty deployment answers from the cache, or from local
1743
+ teams on a host that has not observed its workspace (`oats sync` and
1744
+ `oats teams` observe it). The cache is the running kernel's own: after an
1745
+ OATS upgrade it is empty until the host next observes its workspace.
1746
+
1747
+ **Matching workspaces across machines.** Two deployments are the same
1748
+ workspace when their `key`s are equal; `ref` is never compared. The identity
1749
+ is resolved the same offline way for every deployment, standalone included,
1750
+ whatever its team view:
1751
+
1752
+ - `keyFrom: "member"` is unresolved: never match it, and show it as
1753
+ unresolved (the host is learned when the deployment observes its
1754
+ workspace, e.g. `oats sync`).
1755
+ - `keyFrom: "workspace"` with nothing observed (`teamsFrom: "local"` on a
1756
+ deployment that is not standalone) means the ref was taken as the host, as
1757
+ the schema defines. If it is really a member, the worst case is a split
1758
+ (one workspace shown as two until `oats sync` there), never a wrong merge.
1759
+ - A `null` key is an unusable reference and never matches. Show `ref` with
1760
+ "this deployment's workspace reference isn't valid; fix oats-local.yaml".
1761
+ - Known limit: `parseRepoRef` lowercases the host but keeps the path's case,
1762
+ so references that differ in owner or repository case give different keys.
1763
+
1764
+ **Matching teams across machines.** This is the rule for comparing two
1765
+ deployments' teams (as the Desktop does to attach a remote machine to a
1766
+ workspace):
1767
+
1768
+ - `teamsFrom` `"observed"` or `"cache"`: a `null` team is **unmapped**, and
1769
+ unmapped matches only unmapped.
1770
+ - `standalone: true` with `teamsFrom: "local"`: the local config IS the
1771
+ complete team model, so a `null` team is **unmapped** (matches only
1772
+ unmapped). Reason to show: "teams are local only on this host
1773
+ (standalone)", with no sync advice.
1774
+ - `standalone: false` with `teamsFrom: "local"`: a `null` default team is
1775
+ **unknown** and never matches. Reason to show: "this host hasn't observed
1776
+ its workspace yet; run oats sync there". A non-null default team (a locally
1777
+ mapped team) matches normally.
1778
+
1720
1779
  **Desktop facts** (feature `desktop-facts`): `startedAt` is the last start or
1721
1780
  restart, else `createdAt` for a launched home, else `null`. `modelFrom` is
1722
1781
  `"soul"`, `"spawn"` or `"start"` (an explicit `--model`), `"launch-config"`,
@@ -1738,6 +1797,8 @@ route target:
1738
1797
  {"id":"build:3f2a…","server":"build","label":"Build box","registrationPresent":true,
1739
1798
  "target":{"sshHost":"build-host","workspace":"/srv/team","oatsPath":"oats"},
1740
1799
  "probe":{"ok":true},"agentsRoot":"/srv/team/agents",
1800
+ "workspace":{"reachable":true,"key":"github.com/acme/team","ref":"git:github.com/acme/team","keyFrom":"workspace","standalone":false,"defaultTeam":{"label":"default","team":"acme:team"},
1801
+ "teams":{"default":"acme:team"},"teamsFrom":"observed"},
1741
1802
  "souls":[{"name":"dev","harness":"claude","work":"worktree","agentsRoot":"/srv/team/agents"}],
1742
1803
  "instances":[{"server":"build","instance":"dev-a","agent":"dev","home":"/srv/team/agents/dev/instances/dev-a",
1743
1804
  "agentsRoot":"/srv/team/agents","harness":"claude","backend":"tmux","tmux":{"session":"oats-agents","window":"dev-a"},
@@ -1749,6 +1810,15 @@ route target:
1749
1810
  "retireFailures":[]}
1750
1811
  ```
1751
1812
 
1813
+ - **`workspace`** (feature `workspace-identity`, OATS 0.36.0): the host's
1814
+ own `status --json` [`workspace` object](#workspace-identity-feature-workspace-identity-oats-0360),
1815
+ relayed verbatim, or `null` when the host reports none (a deployment
1816
+ without `oats-local.yaml`, or a failed or skipped probe). A host before
1817
+ 0.36.0 answers the reachability-only object (`{reachable, code?, reason?,
1818
+ message?}`) with no identity fields, so the identity is there only when
1819
+ the object has a `key` field (which a 0.36.0 host always sends, `null` for
1820
+ an unusable reference). It is never derived on this side. It is on the group, not the
1821
+ rows, so an empty remote deployment still reports it.
1752
1822
  - **Instance rows** relay the host's own `status --json` row: `identity`,
1753
1823
  `identityAddress`, `teams`, `startedAt`, `createdAt`, `model`,
1754
1824
  `runtimeState`, `parentInstance`, `siblingInstance`, `relation`,
package/docs/desktop.md CHANGED
@@ -104,6 +104,53 @@ opened, or your home directory when there is none (never ~/Downloads);
104
104
 
105
105
  Launch flags for scripted use: `--dir <workspace>` and `OATS_DESKTOP_PORT`.
106
106
 
107
+ ## One workspace, several machines
108
+
109
+ The switcher lists each workspace once, however many deployments it has: the
110
+ deployments you opened on this Mac and those your registered servers report,
111
+ matched by the workspace each one reports (its repository key and default
112
+ team, by the kernel's rules). It needs a CLI with the `workspace-identity`
113
+ feature (OATS 0.36.0); without it each deployment has its own entry.
114
+
115
+ - **Deployments.** The **Deployments** page (formerly the Active overview)
116
+ shows the workspace's overview trees. **All** stacks one section per
117
+ deployment, each headed by its machine and folder ("This Mac ·
118
+ ~/Agents/oats", "altair · ~/Agents/tsm"). Then each deployment has a tab of
119
+ its own, named by its machine ("altair"; "This Mac · oats-v2" when this Mac
120
+ holds two). A workspace with one deployment shows just that deployment's
121
+ tab. The tab you chose is remembered per workspace. With two or more
122
+ deployments, the sidebar's instance list is grouped under one heading per
123
+ machine; with one, it has no headings.
124
+ - **Not shown live.** A deployment that can't be shown live says why in a few
125
+ words on its heading ("ssh needs a prompt", "Timed out", "OATS too old to
126
+ report its workspace"), and **How to fix** under it gives the full sentence
127
+ and the steps. Causes: ssh needs a prompt or a host key, the read timed out,
128
+ the host's OATS is too old to report its workspace, its workspace reference
129
+ needs `oats sync` or fixing in `oats-local.yaml`, or this computer's OATS
130
+ can't read other machines. A server that can't be reached stays under the
131
+ workspace it last reported, marked "remembered".
132
+ - **The switcher.** Each workspace names its machines on one line ("This Mac ·
133
+ altair"), with a mark when one of them isn't live.
134
+ - **Not matched to a workspace.** A deployment that can't be matched is
135
+ listed on its own under that heading in the switcher, with its machine and
136
+ a short reason; choosing it opens its tab on the Deployments page. When this
137
+ computer's OATS is too old to report workspaces, nothing can be matched:
138
+ each deployment is listed on its own, as before, and its heading says to
139
+ update OATS.
140
+ - **Actions.** Everything you do to an instance goes to that instance's own
141
+ deployment. Workspace-wide pages (Setup, Capabilities, Sync, Automations,
142
+ Schedules, Teams configuration) act on the workspace's first deployment on
143
+ this Mac, else its first, and say "On <deployment>" when there are two or
144
+ more.
145
+ - **Spawn.** With two or more deployments, the Spawn dialog asks first which
146
+ one to spawn in (and says "Runs on" in its summary), starting on the one you last used in that workspace when it
147
+ offers the soul, else the first that does (this Mac first). With one, it
148
+ asks nothing.
149
+
150
+ Saved selections and tabs move to the workspace that holds their
151
+ deployment. Details are in
152
+ [the deployment model](../packages/desktop/docs/desktop-deployment-model.md#workspace-views-and-deployments).
153
+
107
154
  ## Scheduling agents and wake messages
108
155
 
109
156
  Open **Schedules** in the selected workspace to launch a new agent on a cron,
@@ -170,6 +170,12 @@ The task's text never travels on a command line, where any local user could
170
170
  read it in the process list:
171
171
 
172
172
  - pi and Claude Code get `@TASK.md`, which each harness reads as the file.
173
+ pi sends it as the session's first prompt and refuses it if another
174
+ extension's turn (the @awebai/pi welcome) is running or starts while it is
175
+ being sent. The pi bridge (`@awebai/oats-pi`) holds it on pi's own path
176
+ until no turn is active, so it runs exactly once, unaltered; a pi
177
+ deployment without the bridge can sit idle with no task. The exception is
178
+ in the bridge's [README](../packages/pi/README.md).
173
179
  - Codex gets a fixed pointer to the file and reads it with a tool.
174
180
  - A home whose recorded command still hands over `"$(cat TASK.md)"` starts
175
181
  with its harness's safe prompt instead, and the command is saved that way.
@@ -0,0 +1,34 @@
1
+ # OATS 0.35.5
2
+
3
+ ## Fixed
4
+
5
+ - **Adding a workspace never drops a saved deployment that is missing at
6
+ that moment** (awebai/oats#472). A saved deployment on a volume that was
7
+ not mounted yet stayed saved at launch, but adding another workspace then
8
+ saved only the deployments that validated, and it was gone for good. The
9
+ same happened when the Desktop was launched on a new deployment. Now the
10
+ server is still started only with the deployments that are there, while
11
+ `workspace-open.json` keeps every saved one; the next launch serves it again
12
+ once it is back. Only an explicit remove drops a saved deployment (the
13
+ Desktop has no remove action yet).
14
+
15
+ - **A pi instance launched with a task runs it, once, after the @awebai/pi
16
+ welcome** (awebai/oats#469). pi's interactive mode sends `@TASK.md` as the
17
+ session's first prompt with no streamingBehavior (pi 0.85.1
18
+ `interactive-mode.js:816`), so pi refused it ("Agent is already
19
+ processing…") when the @awebai/pi welcome turn was running or started
20
+ while the task was being sent, leaving the instance idle with no task.
21
+ With the bridge, the opening task now runs exactly once, after any turn
22
+ another extension starts, exactly as pi's input processing made it: the
23
+ bridge holds it on pi's own path until no turn is active, and never
24
+ sends, re-sends or alters a message. One exception remains: an extension
25
+ loaded behind the bridge that starts a turn (or awaits I/O while one
26
+ starts) inside its own input or before_agent_start handling of the
27
+ opening prompt can still make pi refuse the task, as before. The bridge
28
+ with @awebai/pi has no such handler and is fully covered. Tested against
29
+ pi 0.85.1; pi before 0.80.4, which has no `agent_settled` event, is
30
+ covered by a model of its behaviour only. The fix ships in
31
+ `@awebai/oats-pi` 0.35.5: a pi deployment must update the bridge to get it
32
+ (`oats update` updates an installed bridge; without one, `pi install
33
+ npm:@awebai/oats-pi`), then restart its pi sessions. The kernel alone does
34
+ not fix it. Claude and Codex launches are unchanged.
@@ -0,0 +1,118 @@
1
+ # OATS 0.36.0
2
+
3
+ ## Added
4
+
5
+ - **A deployment reports its workspace identity** (feature
6
+ `workspace-identity`, awebai/oats#482). `oats status --json`'s `workspace`
7
+ object now also carries `key` (the workspace host's canonical repository
8
+ key, so every spelling of one repository matches, and the host's key when
9
+ `oats-local.yaml` names a member whose backlink is known), `ref` (the
10
+ reference exactly as `oats-local.yaml` writes it, for display), `keyFrom`
11
+ (`"workspace"`, or `"member"` while a member's host is not known),
12
+ `standalone`, `defaultTeam` (`{label, team}`),
13
+ `teams` (`{<label>: <provider team id> | null}`) and `teamsFrom`. They are
14
+ read offline, so they are there whether the workspace is reachable or not.
15
+ Shared teams come from the workspace file the run read
16
+ (`teamsFrom: "observed"`), else this machine's cached copy at the host
17
+ commit it last observed (`"cache"`, no git process), else nowhere
18
+ (`"local"`: local teams only). `oats server roster --json` relays each
19
+ host's `workspace` object verbatim on its group (from a host before
20
+ 0.36.0, the reachability-only object), or `null` from a host that reports
21
+ none. The rule for matching teams across machines (when a
22
+ `null` team is unmapped and when it is unknown) is in
23
+ docs/desktop-cli-api.md, under Workspace identity. A deployment without
24
+ `oats-local.yaml` still has no `workspace` object, and the human-readable
25
+ `oats status` is unchanged. After upgrading, run `oats sync` on each remote
26
+ host (and on any deployment with no instances): the parsed cache belongs to
27
+ the running kernel, so until the deployment observes its workspace again,
28
+ a status that does not read the workspace itself reports
29
+ `teamsFrom: "local"` and its shared default team shows as unknown.
30
+ - **The Desktop shows a workspace's deployments on every machine**
31
+ (awebai/oats#482). The switcher lists each workspace once: its deployments
32
+ on this Mac and on registered servers are matched by the workspace each
33
+ reports (its key and default team, by the kernel's rules in
34
+ docs/desktop-cli-api.md), and named by machine. The Active overview becomes
35
+ **Deployments**: **All** shows the overview trees split by deployment ("This
36
+ Mac · ~/Agents/oats", "altair · ~/Agents/tsm"), then each deployment has its
37
+ own tab. Every action is still addressed to the row's own deployment. A
38
+ deployment that isn't shown live says why in a few words, with **How to fix**
39
+ under it (ssh needs a prompt or a host key, timed out, the host's OATS is too
40
+ old, its workspace reference needs `oats sync` or fixing), and one that can't
41
+ be matched is listed in the switcher under "Not matched to a workspace". The
42
+ Desktop remembers the last workspace each remote reported, so an
43
+ unreachable server stays under its workspace, marked "remembered". With two
44
+ or more deployments the sidebar groups instances by machine, and the Spawn
45
+ dialog asks first which deployment to spawn in; the Teams board shows only
46
+ the workspace's own deployments, grouped by deployment.
47
+ Saved selections and tabs move to the workspace that holds their
48
+ deployment. It needs the CLI's `workspace-identity` feature; without it
49
+ each deployment keeps its own entry, as before.
50
+
51
+ ## Changed
52
+
53
+ - **The Desktop's tabs fit the window, and the selected tab stays in view.**
54
+ Tabs share the tab strip like VS Code's "shrink" mode: each takes at most its
55
+ natural width (still capped at 280px) and they shrink evenly as more open,
56
+ down to 168px; below that the strip scrolls. A shrunk tab keeps the end of
57
+ its name ("oats-…palette"), so tabs whose names share a prefix still read
58
+ differently. The branch gives way before the name, and the full
59
+ name stays in the tooltip and the accessible name. Whenever a tab becomes
60
+ active, a tab closes, or a strip resizes (the window, the sidebar or panel,
61
+ a split), the active tab is scrolled fully into view, in split groups too.
62
+ After the tabs, only Split right and Split down remain. Closing a split
63
+ (⌥⌘W / Ctrl+Shift+Alt+W) and showing or hiding the instance panel
64
+ (⌥⌘B / Ctrl+Alt+B) are on their chords and in the command palette, and the
65
+ panel keeps its own collapse control
66
+ ([desktop-keyboard.md](../../packages/desktop/docs/desktop-keyboard.md#the-command-palette-k)).
67
+ - **The Desktop's command palette lists instances like the sidebar and cycles
68
+ with ⌘K.** With an empty query it shows every instance in the sidebar's
69
+ groups, order and indentation, then the commands. A query keeps that tree,
70
+ showing each match under its dimmed ancestors. While the palette is open,
71
+ ⌘K moves down and ⇧⌘K up, wrapping. Enter opens the row and Esc closes, so
72
+ a second ⌘K no longer closes the palette. On Linux/Windows the chord,
73
+ Ctrl+Shift+P, already holds Shift, so ArrowUp moves up there.
74
+ - **The Desktop's default monospace font is Inconsolata, bundled with the
75
+ app.** The terminal and the UI's code, paths and shortcut hints use it on
76
+ every machine, ahead of the system monospace fonts. The terminal's default
77
+ size is now 15px, and the terminal's text sits closer to the pane's edges
78
+ (12px each side instead of 32px), centred in the pane. Box drawing (Claude
79
+ Code's input box, tmux borders) is drawn as a native terminal draws it, with
80
+ thin lines whatever the font. A terminal font or size you already
81
+ chose is kept, and resetting the terminal's typography (⌘0 / Ctrl+0, the
82
+ command palette, or Settings → Terminal) goes back to 15px. Settings (the sidebar's settings
83
+ button, now titled just "Settings") has a Terminal section with a font size
84
+ stepper (9–28px, or type a size) that changes every open terminal at once
85
+ and follows ⌘= / ⌘- / ⌘0. Inconsolata is under the SIL Open Font License 1.1
86
+ ([fonts/README.md](../../packages/desktop/renderer/fonts/README.md)).
87
+
88
+ - **The Desktop follows a spawn to its new instance.** After **Spawn** the
89
+ dialog closes at once and the pending row is revealed in the roster, without
90
+ taking focus. When the instance is running, the Desktop opens its terminal
91
+ tab and selects its row: arriving there is the confirmation, so a successful
92
+ spawn no longer posts a toast. It never pulls the operator away from
93
+ something they did since the press: typing (a lone modifier does not count),
94
+ pasting, moving focus, opening another tab or view, or an open dialog or
95
+ overlay. Then they stay where they are, and the row says **New** until it or
96
+ its tab is first opened. Either way, assistive technology hears "*name*
97
+ spawned". Partial, refused, failed and unknown outcomes keep their
98
+ notifications
99
+ ([desktop-spawn-preview.md](../../packages/desktop/docs/desktop-spawn-preview.md)).
100
+
101
+ - **The Desktop's spawn dialog opens on Name and jumps between its sections.**
102
+ Name is focused whenever the dialog opens for a chosen soul, Quick Open
103
+ included, with the caret after a restored name. **⌘1–⌘7** (Ctrl+1–7
104
+ elsewhere) jump to Name, Harness, Model, Relationship, Teams and the Opening
105
+ instruction; ⌘7 opens or closes Developer settings. Quiet hints show the
106
+ keys; the shortcuts editor lists them under *Spawn dialog*, where they can
107
+ be rebound, and the shell's own ⌘1–9 do not fire behind the dialog
108
+ ([desktop-keyboard.md](../../packages/desktop/docs/desktop-keyboard.md)).
109
+ In "Works in", a directory instance now reads "own folder · free to work
110
+ across repos".
111
+
112
+ - **The Active overview no longer has a Spawn button.** Spawn from the
113
+ canvas with **S**, from the sidebar's **Spawn instance** (⌘N), from Quick
114
+ Open or from a soul card.
115
+
116
+ - **The Desktop accepts OATS CLIs `>=0.25.8 <0.37.0`**, so it runs against
117
+ this release's kernel. Install the CLI and the Desktop 0.36.0 together: the
118
+ Desktop 0.35.x refuses a 0.36 CLI.
package/docs/servers.md CHANGED
@@ -156,7 +156,9 @@ oats okf harvest --server build --instance dev-fix-123 # the harvest, run in t
156
156
 
157
157
  The **roster** is what the Desktop shows: one group per server id and route
158
158
  target (host and workspace), with the registration (present or not), the
159
- probe result, the remote souls, the instances joined with saved routes
159
+ probe result, the host's workspace identity (`workspace`: its own `status
160
+ --json` `workspace` object relayed verbatim, reachability only from a host
161
+ before 0.36.0, `null` when it reports none or the probe failed), the remote souls, the instances joined with saved routes
160
162
  (`savedRoute`, `running` or `null` when unknown, `retirePending`,
161
163
  `rollbackIncomplete`, `missingRemotely`, `addressable`), and `retireFailures`
162
164
  (deferred self-retirements that failed there). Each instance row also relays
package/lib/servers.mjs CHANGED
@@ -739,7 +739,7 @@ export function rosterGroups({ server, io = {} } = {}) {
739
739
  const groups = new Map();
740
740
  const add = (serverId, target, registrationPresent, label) => {
741
741
  const key = `${serverId}:${targetKey(target)}`;
742
- if (!groups.has(key)) groups.set(key, { id: key, server: serverId, label: label || serverId, registrationPresent, target, probe: null, agentsRoot: undefined, souls: [], instances: [], retireFailures: [], _snapshots: [] });
742
+ if (!groups.has(key)) groups.set(key, { id: key, server: serverId, label: label || serverId, registrationPresent, target, probe: null, agentsRoot: undefined, workspace: null, souls: [], instances: [], retireFailures: [], _snapshots: [] });
743
743
  const g = groups.get(key);
744
744
  if (registrationPresent) g.registrationPresent = true;
745
745
  return g;
@@ -769,6 +769,9 @@ export function rosterGroups({ server, io = {} } = {}) {
769
769
  if (status?.ok) {
770
770
  g.probe = { ok: true };
771
771
  g.agentsRoot = status.result.root;
772
+ // The host's own workspace identity (feature workspace-identity), relayed as it answered it: never
773
+ // derived here, null when the host reports none.
774
+ g.workspace = status.result.workspace ?? null;
772
775
  for (const a of status.result.agents || []) {
773
776
  g.souls.push({ name: a.name, harness: a.harness ?? a.runtime, work: a.work, backend: a.backend, description: a.description, agentsRoot: status.result.root });
774
777
  // A failed deferred self-retirement needs an operator: it rides with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.35.4",
3
+ "version": "0.36.0",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",