synomem 0.4.0 → 0.5.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 (47) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +50 -10
  3. package/dist/cli.d.ts +6 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +179 -16
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +2 -1
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +37 -1
  10. package/dist/client.js.map +1 -1
  11. package/dist/configure.d.ts.map +1 -1
  12. package/dist/configure.js +31 -15
  13. package/dist/configure.js.map +1 -1
  14. package/dist/credentials.d.ts.map +1 -1
  15. package/dist/credentials.js +9 -1
  16. package/dist/credentials.js.map +1 -1
  17. package/dist/discover.d.ts +48 -0
  18. package/dist/discover.d.ts.map +1 -0
  19. package/dist/discover.js +106 -0
  20. package/dist/discover.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/projections.d.ts.map +1 -1
  26. package/dist/projections.js +15 -7
  27. package/dist/projections.js.map +1 -1
  28. package/dist/service.d.ts +2 -1
  29. package/dist/service.d.ts.map +1 -1
  30. package/dist/storage.d.ts +5 -0
  31. package/dist/storage.d.ts.map +1 -1
  32. package/dist/storage.js +6 -0
  33. package/dist/storage.js.map +1 -1
  34. package/dist/types.d.ts +26 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/docs/cli.md +67 -3
  37. package/package.json +1 -1
  38. package/src/cli.ts +228 -20
  39. package/src/client.ts +41 -1
  40. package/src/configure.ts +30 -14
  41. package/src/credentials.ts +9 -1
  42. package/src/discover.ts +155 -0
  43. package/src/index.ts +2 -0
  44. package/src/projections.ts +17 -7
  45. package/src/service.ts +8 -0
  46. package/src/storage.ts +9 -0
  47. package/src/types.ts +21 -0
package/CHANGELOG.md CHANGED
@@ -3,7 +3,48 @@
3
3
  All notable changes will be documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases follow Semantic Versioning.
5
5
 
6
- ## [Unreleased]
6
+ ## [0.5.0] - 2026-09-07
7
+
8
+ ### Added
9
+
10
+ - **`synomem backend status`** — connects to the selected backend and reports
11
+ what answered. `backend show` still reads the configuration file and connects
12
+ to nothing; a person debugging a broken setup needs that, and a person
13
+ confirming a working one needs a connection to have been made.
14
+ - **`synomem projection status`** — whether the generated files match the
15
+ canonical events, when they were last rebuilt, and which ones drifted. The
16
+ comparison is against Synomem's own manifest, so a file somebody added to the
17
+ projection tree by hand is not reported as drift.
18
+ - **`synomem remote workspaces`** — the organizations and workspaces a
19
+ credential can reach, with the IDs `backend use remote --workspace` takes.
20
+ - `agent runtime list` with no agent named lists every agent that runs
21
+ anywhere. Requiring the agent meant already knowing the answer to the
22
+ question being asked.
23
+
24
+ ### Changed
25
+
26
+ - **Remote setup no longer asks for a workspace ID.** An installation access
27
+ key is bound to exactly one workspace, so the service is asked which one
28
+ rather than the person; `config init --backend remote --access-token-stdin`
29
+ needs no `--workspace`. Typing `ws-04psqx2rkt8ttft7a1t2z69r97` from memory
30
+ was never something a person could do.
31
+ - The Windows credential-store choice is the restricted file rather than
32
+ Credential Manager, which the credential layer does not implement. Offering a
33
+ store that cannot read its own credential back fails on first use, after
34
+ setup has already claimed the credential was safe.
35
+
36
+ ### Fixed
37
+
38
+ - `doctor` reported every workspace's projections as stale once any agent had a
39
+ generated ID: the expected-path list was built from canonical IDs while the
40
+ files, the manifest and the cleanup all used handles.
41
+ - `doctor`'s agent-directory symbolic-link check inspected a path built from the
42
+ canonical ID, so it examined a directory that does not exist and passed on a
43
+ workspace whose agent directory really had been replaced with a link.
44
+
45
+ ## [0.4.0] - 2026-09-07
46
+
47
+ Published as 0.3.0 and 0.4.0 on the same day; the entries below cover both.
7
48
 
8
49
  ### Changed
9
50
 
package/README.md CHANGED
@@ -14,20 +14,24 @@
14
14
 
15
15
  [CLI reference](docs/cli.md) · [MCP guide](docs/mcp.md) · [Storage](docs/storage-format.md) · [Security](SECURITY.md)
16
16
 
17
- The experimental remote client supports explicit backend configuration. `synomem backend use remote
18
- --url <https-origin> --workspace <id>` makes the CLI and stdio MCP use the versioned domain API with
19
- actor-scoped OAuth credentials; it does not synchronize local history or create a shadow SQLite
20
- database. Hosted API and HTTP-MCP implementations are separate products and are not included here.
21
- See the [CLI reference](docs/cli.md#backend-and-authentication).
17
+ Synomem runs two ways, and the CLI, the library and the MCP server behave identically on both.
18
+ **Local** keeps an append-only SQLite database on this machine, needs no account, and opens no
19
+ network listener. **Synomem Cloud** keeps canonical state in a hosted workspace shared across
20
+ machines and agents, with organizations, roles and administration. `synomem config` sets up either
21
+ one. Nothing is synchronized between them and choosing the hosted backend never creates a shadow
22
+ local database. See the [CLI reference](docs/cli.md#backend-and-authentication).
22
23
 
23
24
  </div>
24
25
 
25
- Synomem gives humans and AI agents four durable ways to coordinate beyond a disappearing chat:
26
+ Synomem gives humans and AI agents durable ways to coordinate beyond a disappearing chat:
26
27
 
27
28
  - **Kudos** recognize a concrete contribution.
28
29
  - **Memos** deliver a message to another agent or to one's future self.
29
30
  - **Notes** retain agent-owned, revisable knowledge.
30
- - **Todos** track assigned actions with optional date-only or timezone-aware deadlines.
31
+ - **Posts** announce something to everyone in the workspace, and record who has acknowledged it.
32
+ - **Tasks** delegate work to another agent, with their consent.
33
+ - **Todos** track an agent's own actions, private to them, with optional date-only or
34
+ timezone-aware deadlines.
31
35
 
32
36
  ## Core philosophy
33
37
 
@@ -38,9 +42,11 @@ collaboration through one auditable protocol. V1 provides that substrate locally
38
42
  designed so the same agent identities and semantics can later cross machines through an explicitly
39
43
  configured service.
40
44
 
41
- One append-only SQLite event store powers the TypeScript library, `synomem` CLI, actor-bound stdio
42
- MCP server, compact change feeds, and readable Markdown projections. V1 runs entirely on one machine
43
- and opens no network listener.
45
+ One append-only event store powers the TypeScript library, `synomem` CLI, actor-bound stdio MCP
46
+ server, compact change feeds, and readable Markdown projections. On the local backend that store is
47
+ SQLite on this machine, and nothing listens on the network. On Synomem Cloud it is a hosted
48
+ Postgres workspace reached over HTTPS with actor-scoped credentials. Events are never rewritten on
49
+ either one.
44
50
 
45
51
  > [!IMPORTANT]
46
52
  > Synomem is pre-1.0 software. Review the release notes before upgrading persisted storage or public
@@ -50,7 +56,34 @@ and opens no network listener.
50
56
 
51
57
  ```bash
52
58
  npm install --global synomem
59
+ synomem config # asks where state should live, then sets it up
60
+ ```
61
+
62
+ `synomem config` is interactive. Its deterministic equivalents, for a machine with no terminal:
63
+
64
+ ```bash
65
+ # Local: SQLite on this machine, no account.
66
+ synomem config init --backend local --yes
67
+
68
+ # Synomem Cloud with an installation access key from
69
+ # https://portal.synomem.ai/installations. The key names its own workspace, so
70
+ # there is no ID to look up -- and it is piped rather than passed as an
71
+ # argument, which the shell history and the process list would both keep.
72
+ printf '%s' "$SYNOMEM_KEY" | synomem config init \
73
+ --backend remote --auth access-key --access-token-stdin --yes
74
+ ```
75
+
76
+ Check either one at any time:
53
77
 
78
+ ```bash
79
+ synomem backend status # connects, and reports what answered
80
+ synomem projection status # local only: are the generated files current?
81
+ synomem doctor
82
+ ```
83
+
84
+ Everything below works the same on both backends.
85
+
86
+ ```bash
54
87
  export SYNOMEM_HOME="$(mktemp -d)/.synomem"
55
88
  synomem init
56
89
  synomem agent create codex --name "Codex"
@@ -78,8 +111,15 @@ synomem task accept <task-id> --as codex --response "Starting after the tests."
78
111
  # A todo is private to the agent that wrote it; nobody else can assign one.
79
112
  synomem todo create --as codex --title "Re-read the migration notes"
80
113
 
114
+ # A post is readable by everyone in the workspace, and tracks acknowledgement.
115
+ synomem post create --as gracie \
116
+ --title "Migration tonight" --body "Expect a short read-only window."
117
+ synomem post acknowledge <post-id> --as codex --note "Already handled."
118
+ synomem post roster <post-id>
119
+
81
120
  synomem agent resolve Mike
82
121
  synomem agent directory
122
+ synomem agent runtime list
83
123
  synomem list
84
124
  ```
85
125
 
package/dist/cli.d.ts CHANGED
@@ -23,6 +23,12 @@ export interface CliDependencies {
23
23
  reference: string;
24
24
  credentialStore: CredentialStore;
25
25
  }) => Promise<void>;
26
+ discoverBoundWorkspace?: (options: {
27
+ baseUrl: string;
28
+ accessToken: string;
29
+ }) => Promise<{
30
+ workspaceId: string;
31
+ }>;
26
32
  createImportBundle?: (home: string) => Promise<ImportBundle>;
27
33
  remoteImport?: (options: {
28
34
  baseUrl: string;
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAE,OAAO,EAA0B,MAAM,WAAW,CAAC;AAkB5D,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIhG,OAAO,EAA4C,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,OAAO,KAAK,EACV,aAAa,EAQd,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAkB,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,eAAe,CAAC;KAClC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpB,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,aAAa,GAAG,YAAY,CAAC,CAAC;CAC7C;AAkND,wBAAgB,SAAS,CACvB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAuqET;AAED,wBAAsB,MAAM,CAC1B,IAAI,WAAe,EACnB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAAC,MAAM,CAAC,CAqBjB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAE,OAAO,EAA0B,MAAM,WAAW,CAAC;AAmB5D,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIhG,OAAO,EAA4C,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,OAAO,KAAK,EACV,aAAa,EASd,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAkB,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,eAAe,CAAC;KAClC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAKpB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;KACrB,KAAK,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvC,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,aAAa,GAAG,YAAY,CAAC,CAAC;CAC7C;AAkND,wBAAgB,SAAS,CACvB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CA62ET;AAED,wBAAsB,MAAM,CAC1B,IAAI,WAAe,EACnB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAAC,MAAM,CAAC,CAqBjB"}
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { configuredServiceFactory, readSynomemConfig, writeSynomemBackend } from
8
8
  import { cloudApiUrl } from './cloud.js';
9
9
  import { resolveHome } from './config.js';
10
10
  import { assertInteractive, confirmPlan, credentialFingerprint, credentialStoreChoices, environmentInstructions, readAccessToken, runConfigWizard, writeCredentialFile, } from './configure.js';
11
+ import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
11
12
  import { defaultPromptIo } from './prompt.js';
12
13
  import { credentialReference, OsCredentialStore } from './credentials.js';
13
14
  import { asSynomemError, SynomemError } from './errors.js';
@@ -198,6 +199,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
198
199
  const env = dependencies.env ?? process.env;
199
200
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
200
201
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
202
+ const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
201
203
  const promptIo = dependencies.promptIo ?? defaultPromptIo();
202
204
  const verifyRemoteCredential = dependencies.verifyRemoteCredential ??
203
205
  (async (options) => {
@@ -226,6 +228,41 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
226
228
  .showSuggestionAfterError()
227
229
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
228
230
  const remoteCommand = program.command('remote').description('Administer a remote workspace');
231
+ /*
232
+ * The browser counterpart to an access key naming its own workspace.
233
+ *
234
+ * A signed-in account may reach several organizations, each with several
235
+ * workspaces, so there is a genuine choice to make -- and no way to make it
236
+ * without seeing the list. Printing the IDs alongside the names is the point:
237
+ * the ID is what `backend use remote --workspace` takes.
238
+ */
239
+ remoteCommand
240
+ .command('workspaces')
241
+ .description('List the organizations and workspaces this credential can reach')
242
+ .option('--url <url>', 'internal: alternate HTTPS origin')
243
+ .action(async (options, command) => {
244
+ const global = globals(command);
245
+ const config = readSynomemConfig(global.home, env);
246
+ const baseUrl = options.url ??
247
+ (config?.backend.kind === 'remote' ? config.backend.baseUrl : cloudApiUrl(env));
248
+ const accessToken = env.SYNOMEM_ACCESS_TOKEN;
249
+ if (!accessToken) {
250
+ throw new SynomemError('AUTH_REQUIRED', 'Set SYNOMEM_ACCESS_TOKEN, or run `synomem auth login` first.');
251
+ }
252
+ const organizations = await discoverOrganizations({ baseUrl, accessToken });
253
+ const choices = workspaceChoices(organizations);
254
+ const human = organizations.length
255
+ ? organizations
256
+ .map((organization) => [
257
+ `${organization.displayName} (${organization.slug}) — ${organization.role}`,
258
+ ...(organization.workspaces.length
259
+ ? organization.workspaces.map((workspace) => ` ${workspace.id} ${workspace.displayName}`)
260
+ : [' (no workspaces yet)']),
261
+ ].join('\n'))
262
+ .join('\n')
263
+ : 'This account belongs to no organizations yet.';
264
+ output(io, global.json, { organizations, choices }, human);
265
+ });
229
266
  remoteCommand
230
267
  .command('import')
231
268
  .description('Preview or confirm a one-way import from a local Synomem home')
@@ -308,13 +345,45 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
308
345
  .description('Set up Synomem, interactively or deterministically');
309
346
  const applyPlan = async (plan, token, global) => {
310
347
  const home = plan.home;
348
+ const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
349
+ /*
350
+ * The workspace is discovered, not typed.
351
+ *
352
+ * An installation access key is bound to exactly one workspace, so the
353
+ * service can be asked which one rather than the person. An explicit
354
+ * --workspace still wins, because automation should not depend on a
355
+ * network round trip to configure a machine.
356
+ */
357
+ let workspaceId = plan.workspaceId;
358
+ if (plan.backend === 'remote' && !workspaceId && token) {
359
+ const bound = await discoverWorkspace({ baseUrl: serviceUrl, accessToken: token });
360
+ workspaceId = bound.workspaceId;
361
+ io.stdout(`Access key is bound to workspace ${workspaceId}.\n`);
362
+ }
363
+ if (plan.backend === 'remote' && !workspaceId) {
364
+ /*
365
+ * Signing in through a browser needs an actor identity and a client ID,
366
+ * which is `synomem auth login`'s job. Rather than write a remote
367
+ * backend with no workspace -- a configuration that fails on its first
368
+ * real use -- say exactly what remains.
369
+ */
370
+ output(io, global.json, { applied: false, pending: 'sign-in', home, serviceUrl }, [
371
+ '',
372
+ 'Nothing was configured yet: signing in through a browser is a separate step.',
373
+ '',
374
+ 'Run, with the actor this machine acts as:',
375
+ '',
376
+ ' synomem auth login --actor-id <agent> --client-id <client>',
377
+ '',
378
+ 'Then select the workspace it reports:',
379
+ '',
380
+ ' synomem backend use remote --workspace <workspace-id>',
381
+ ].join('\n'));
382
+ return;
383
+ }
311
384
  const config = writeSynomemBackend(plan.backend === 'local'
312
385
  ? { kind: 'local' }
313
- : {
314
- kind: 'remote',
315
- baseUrl: plan.serviceUrl ?? cloudApiUrl(env),
316
- workspaceId: plan.workspaceId,
317
- }, home);
386
+ : { kind: 'remote', baseUrl: serviceUrl, workspaceId: workspaceId }, home);
318
387
  let credentialLocation;
319
388
  if (token) {
320
389
  if (plan.credentialStore === 'environment') {
@@ -328,7 +397,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
328
397
  // The platform store is the default, and a failure falls back to the
329
398
  // restricted file rather than leaving the credential nowhere.
330
399
  try {
331
- await credentialStore.set(`synomem:${plan.workspaceId}`, {
400
+ await credentialStore.set(`synomem:${workspaceId}`, {
332
401
  kind: 'installation-key',
333
402
  accessToken: token,
334
403
  });
@@ -392,8 +461,10 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
392
461
  throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
393
462
  }
394
463
  const backend = options.backend;
395
- if (backend === 'remote' && !options.workspace) {
396
- throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace.');
464
+ // An access key names its own workspace, so --workspace is only
465
+ // required when there is no key to ask.
466
+ if (backend === 'remote' && !options.workspace && !options.accessTokenStdin) {
467
+ throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.');
397
468
  }
398
469
  const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
399
470
  if (backend === 'remote' && options.auth === 'access-key' && !token) {
@@ -541,6 +612,80 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
541
612
  }, global.home);
542
613
  output(io, global.json, { backend: config.backend }, `Selected ${config.backend.kind} Synomem backend.`);
543
614
  });
615
+ /*
616
+ * `show` reads the config file; `status` proves the selection actually works.
617
+ *
618
+ * The two are deliberately separate. A person debugging a broken setup needs
619
+ * to know what is configured even when nothing can be reached, and a person
620
+ * checking that a setup is live needs a connection to have been made. One
621
+ * command doing both would make a printed workspace ID look like a reachable
622
+ * workspace.
623
+ */
624
+ backendCommand
625
+ .command('status')
626
+ .description('Connect to the selected backend and report what answered')
627
+ .action(async (_options, command) => {
628
+ const global = globals(command);
629
+ const config = readSynomemConfig(global.home);
630
+ if (!config) {
631
+ throw new SynomemError('CONFIG_INVALID', 'No Synomem home here yet. Run `synomem config init` first.');
632
+ }
633
+ const result = await withClient(global.home, defaultActor(env, 'system', 'cli'), async (client) => ({
634
+ info: await client.info(),
635
+ capabilities: await client.capabilities(),
636
+ diagnostics: (await client.doctor()).diagnostics.filter((item) => item.level === 'error' || item.level === 'warning'),
637
+ }));
638
+ const { info, capabilities, diagnostics } = result;
639
+ const where = info.backend === 'local'
640
+ ? `Home: ${info.home}\nDatabase: ${info.databasePath}`
641
+ : `URL: ${info.baseUrl}`;
642
+ const problems = diagnostics.length
643
+ ? diagnostics
644
+ .map((item) => `${item.level.toUpperCase()} ${item.code}: ${item.message}`)
645
+ .join('\n')
646
+ : 'No warnings or errors.';
647
+ const human = [
648
+ `Backend: ${info.backend} (reachable)`,
649
+ where,
650
+ `Workspace: ${capabilities.binding.workspaceId}`,
651
+ `Acting as: ${capabilities.binding.actor.kind} ${capabilities.binding.actor.id}`,
652
+ problems,
653
+ ].join('\n');
654
+ output(io, global.json, { reachable: true, info, capabilities, diagnostics }, human);
655
+ if (diagnostics.some((item) => item.level === 'error'))
656
+ cliExitCodes.set(program, 5);
657
+ });
658
+ const projectionCommand = program
659
+ .command('projection')
660
+ .description('Inspect the generated files Synomem derives from events');
661
+ projectionCommand
662
+ .command('status')
663
+ .description('Report whether the generated files match the canonical events')
664
+ .action(async (_options, command) => {
665
+ const global = globals(command);
666
+ const status = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => {
667
+ if (!client.projectionStatus) {
668
+ throw new SynomemError('INVALID_INPUT', 'The remote backend keeps no filesystem projections, so there is nothing to report.');
669
+ }
670
+ return client.projectionStatus();
671
+ });
672
+ const enabled = Object.entries(status.settings)
673
+ .filter(([, on]) => on)
674
+ .map(([name]) => name);
675
+ const lines = [
676
+ `Directory: ${status.directory ?? '(none)'}`,
677
+ `Enabled: ${enabled.length ? enabled.join(', ') : 'none'}`,
678
+ `Last rebuilt: ${status.lastRebuiltAt ?? 'never'}`,
679
+ status.current
680
+ ? `Current: ${status.counts.manifest} generated file(s) match the events.`
681
+ : `Stale: ${status.counts.missing} missing, ${status.counts.unexpected} no longer expected. Run \`synomem rebuild\`.`,
682
+ ];
683
+ for (const path of status.missing)
684
+ lines.push(` missing ${path}`);
685
+ for (const path of status.unexpected)
686
+ lines.push(` unexpected ${path}`);
687
+ output(io, global.json, status, lines.join('\n'));
688
+ });
544
689
  const authCommand = program.command('auth').description('Inspect remote authentication');
545
690
  authCommand
546
691
  .command('status')
@@ -878,18 +1023,36 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
878
1023
  }));
879
1024
  output(io, global.json, binding, `Bound ${binding.agentId} to ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} (${binding.id})`);
880
1025
  });
1026
+ /*
1027
+ * With no agent named this answers the question people actually arrive with:
1028
+ * "where is any of my stuff running?". Naming an agent narrows it. Requiring
1029
+ * the agent, as this once did, means you must already know the answer to the
1030
+ * question you came to ask.
1031
+ */
881
1032
  runtimeCommand
882
- .command('list <agent>')
883
- .description('List an agent runtime bindings')
1033
+ .command('list [agent]')
1034
+ .description('List runtime bindings for one agent, or for every agent')
884
1035
  .action(async (agent, _options, command) => {
885
1036
  const global = globals(command);
886
- const bindings = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) => client.agents.bindings(agent));
887
- const human = bindings.length
888
- ? bindings
889
- .map((binding) => `${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`)
1037
+ const result = await withClient(global.home, defaultActor(env, 'system', 'cli'), async (client) => {
1038
+ if (agent) {
1039
+ const profile = await client.agents.get(agent);
1040
+ return [{ profile, runtimeBindings: await client.agents.bindings(agent) }];
1041
+ }
1042
+ return (await client.agents.directory()).filter((entry) => entry.runtimeBindings.length > 0);
1043
+ });
1044
+ const describe = (binding) => ` ${binding.id} ${binding.runtime}${binding.profile ? `/${binding.profile}` : ''} bound ${binding.boundAt}`;
1045
+ const human = result.length
1046
+ ? result
1047
+ .map((entry) => [
1048
+ `${entry.profile.handle} (${entry.profile.id})`,
1049
+ ...entry.runtimeBindings.map(describe),
1050
+ ].join('\n'))
890
1051
  .join('\n')
891
- : 'No runtime bindings.';
892
- output(io, global.json, { bindings }, human);
1052
+ : agent
1053
+ ? 'No runtime bindings.'
1054
+ : 'No agent in this workspace has a runtime binding.';
1055
+ output(io, global.json, { agents: result }, human);
893
1056
  });
894
1057
  runtimeCommand
895
1058
  .command('unbind <binding-id>')