sfora-cli 0.13.1 → 0.15.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/README.md CHANGED
@@ -399,3 +399,70 @@ print nothing, and `sfora url` on one says so.
399
399
  Reads outside `projects/<slug>/(posts|drafts)`, `inbox/mentions.md`, and
400
400
  `me/api-key` return `ENOENT`; writes outside the post/draft dirs return `EACCES`.
401
401
  ```
402
+
403
+ ## macOS and shared local operations
404
+
405
+ The desktop app and npm CLI use the same Node-only `sfora-cli/local-core`
406
+ operations. `sfora desktop path/to/file.md` opens a local file in the installed
407
+ Sfora macOS app. `sfora open` continues to open Sfora web URLs. npm installation
408
+ still requires Node 20+; the desktop distribution bundles its own CLI runtime.
409
+ Both read the existing `~/.sfora/config.json` profiles. Config writes are atomic
410
+ and process-locked; no account is required for local files or local skill scans.
411
+
412
+ Local Markdown snapshots contain the canonical `path`, unmodified UTF-8
413
+ `content`, and SHA-256 `revision`. Saving requires the loaded revision; Save As
414
+ refuses existing destinations. BOM and line endings are preserved. A stale
415
+ revision reports a conflict instead of overwriting an external edit. Locks
416
+ coordinate Sfora processes; other editors do not participate in those locks.
417
+ A crashed process may leave a `.sfora-lock` directory: the error identifies it
418
+ for removal after confirming no process is using it.
419
+
420
+ ## Project skills
421
+
422
+ Skills are complete recursive bundles: `SKILL.md`, scripts, references, and
423
+ binary assets retain exact bytes and executable flags. Import/install never
424
+ executes skill scripts. Symbolic links, traversal, duplicate case-insensitive
425
+ paths, invalid hashes and oversized bundles are rejected (1,000 files,
426
+ 5 MiB/file, 20 MiB total).
427
+
428
+ ```sh
429
+ sfora skills scan ~/.agents/skills ~/.codex/skills --json
430
+ sfora skills list --project my-project
431
+ sfora skills push ./my-skill --project my-project --expected-version 0
432
+ sfora skills pull my-skill ./my-skill.bundle.json --project my-project
433
+ sfora skills install my-skill --project my-project --skills-target ~/.agents/skills
434
+ sfora skills diff ~/.agents/skills/my-skill my-skill --project my-project
435
+ sfora skills uninstall ~/.agents/skills/my-skill
436
+ ```
437
+
438
+ Push uses an explicit base version (`0` creates a skill); updating an existing
439
+ skill also requires its `--expected-revision` draft token from `skills list`.
440
+ Saving the draft and publishing are separately checked operations, so a
441
+ concurrent change cannot silently publish somebody else's draft. Use
442
+ `--version N` to pull/install a particular immutable release. Offline bundle
443
+ installation is `skills install-file bundle.json --skills-target <directory>`.
444
+
445
+ Install targets are explicit. A sibling `.name.sfora-install.json` records
446
+ ownership and last installed hash. Existing unowned folders are never replaced;
447
+ updates and uninstalls refuse locally modified installations. Save or push
448
+ those modifications before retrying. `skills pull` writes a new bundle JSON
449
+ file and refuses to overwrite an existing file.
450
+
451
+ Cloud document callers can use `SforaApiClient.readPathSnapshot()` and pass its
452
+ revision to `writePath(..., { expectedRevision })`. CLI writes accept
453
+ `put <path> <file.md> --expected-revision N`. A null read revision means the
454
+ server did not advertise concurrency support; clients must not infer a token.
455
+
456
+ Published Skills are also mounted read-only in the ordinary filesystem view:
457
+
458
+ ```sh
459
+ sfora ls /projects/my-project/skills
460
+ sfora ls /projects/my-project/skills/my-skill/scripts
461
+ sfora cat /projects/my-project/skills/my-skill/SKILL.md
462
+ ```
463
+
464
+ Nested directories, binary reads and executable metadata are preserved. Draft
465
+ changes appear here only after publication; use `skills push` or the project
466
+ workbench to change a bundle. Cloud installations record both the immutable
467
+ bundle hash and numeric `sourceVersion`; resolving “latest” pins that version
468
+ before downloading so concurrent publications cannot mix provenance and bytes.
package/dist/SforaFs.js CHANGED
@@ -76,6 +76,8 @@ function classify(path) {
76
76
  if (seg.length === 2)
77
77
  return { kind: "projectDir", slug };
78
78
  const dir = seg[2];
79
+ if (dir === "skills")
80
+ return { kind: "skillPath", slug, name: seg[3], path: seg.slice(4).join("/") };
79
81
  if (dir === "links.md" && seg.length === 3) {
80
82
  return { kind: "linksFile", slug };
81
83
  }
@@ -560,7 +562,20 @@ export class SforaFs {
560
562
  throw enoent("open", normalize(path));
561
563
  }
562
564
  }
565
+ async #skillManifest(slug, name) {
566
+ const manifest = await this.#client.readSkillManifest(slug, name);
567
+ const paths = new Set();
568
+ for (const file of manifest.files) {
569
+ const parts = file.path.split("/");
570
+ for (let count = 1; count <= parts.length; count++)
571
+ paths.add(parts.slice(0, count).join("/"));
572
+ }
573
+ this.#entrySnapshots.set(`/projects/${slug}/skills/${name}`, [...paths]);
574
+ return manifest;
575
+ }
563
576
  async readFile(path, options) {
577
+ if (classify(path).kind === "skillPath")
578
+ return Buffer.from(await this.readFileBuffer(path)).toString(toNodeEncoding(readEncoding(options)));
564
579
  const text = await this.#readText(path);
565
580
  const enc = readEncoding(options);
566
581
  if (enc === "utf8" || enc === "utf-8")
@@ -568,12 +583,25 @@ export class SforaFs {
568
583
  return Buffer.from(text, "utf8").toString(toNodeEncoding(enc));
569
584
  }
570
585
  async readFileBuffer(path) {
586
+ const loc = classify(path);
587
+ if (loc.kind === "skillPath") {
588
+ if ((await this.stat(path)).isDirectory)
589
+ throw eisdir("read", normalize(path));
590
+ try {
591
+ return await this.#client.readSkillFile(loc.slug, loc.name, loc.path);
592
+ }
593
+ catch (error) {
594
+ throw fromApi(error, "open", normalize(path));
595
+ }
596
+ }
571
597
  const text = await this.#readText(path);
572
598
  return new TextEncoder().encode(text);
573
599
  }
574
600
  async writeFile(path, content, _options) {
575
601
  const loc = classify(path);
576
602
  const norm = normalize(path);
603
+ if (loc.kind === "skillPath")
604
+ throw eacces("open", norm);
577
605
  if (loc.kind === "inboxFile" || loc.kind === "meFile") {
578
606
  throw eacces("open", norm);
579
607
  }
@@ -746,6 +774,21 @@ export class SforaFs {
746
774
  const norm = normalize(path);
747
775
  try {
748
776
  switch (loc.kind) {
777
+ case "skillPath": {
778
+ if (!loc.name) {
779
+ await this.#client.listSkillDirectories(loc.slug);
780
+ return this.#dirStat();
781
+ }
782
+ const manifest = await this.#skillManifest(loc.slug, loc.name);
783
+ if (!loc.path)
784
+ return this.#dirStat();
785
+ const file = manifest.files.find(file => file.path === loc.path);
786
+ if (file)
787
+ return { ...this.#fileStat(new Date(0), file.size), mode: file.executable ? 0o755 : FILE_MODE };
788
+ if (manifest.files.some(file => file.path.startsWith(`${loc.path}/`)))
789
+ return this.#dirStat();
790
+ throw enoent("stat", norm);
791
+ }
749
792
  case "root":
750
793
  case "projectsDir":
751
794
  case "inboxDir":
@@ -853,6 +896,27 @@ export class SforaFs {
853
896
  });
854
897
  try {
855
898
  switch (loc.kind) {
899
+ case "skillPath": {
900
+ if (!loc.name) {
901
+ const directories = (await this.#client.listSkillDirectories(loc.slug)).directories;
902
+ this.#entrySnapshots.set(`/projects/${loc.slug}/skills`, directories.map(skill => skill.name));
903
+ return directories.map(skill => dirent(skill.name, true));
904
+ }
905
+ const manifest = await this.#skillManifest(loc.slug, loc.name);
906
+ if (manifest.files.some(file => file.path === loc.path))
907
+ throw enotdir("scandir", norm);
908
+ const prefix = loc.path ? `${loc.path}/` : "";
909
+ const children = new Map();
910
+ for (const file of manifest.files) {
911
+ if (!file.path.startsWith(prefix))
912
+ continue;
913
+ const parts = file.path.slice(prefix.length).split("/");
914
+ children.set(parts[0], parts.length > 1);
915
+ }
916
+ if (loc.path && !children.size)
917
+ throw enoent("scandir", norm);
918
+ return [...children].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, directory]) => dirent(name, directory));
919
+ }
856
920
  case "root":
857
921
  return [
858
922
  dirent("inbox", true),
@@ -884,6 +948,7 @@ export class SforaFs {
884
948
  dirent("map.md", false),
885
949
  dirent("posts", true),
886
950
  dirent("pulls", true),
951
+ dirent("skills", true),
887
952
  ];
888
953
  // `public/` only appears once the board is shared publicly.
889
954
  if ((await this.#publicSlug(loc.slug)) != null) {
@@ -998,6 +1063,7 @@ export class SforaFs {
998
1063
  const loc = classify(path);
999
1064
  const norm = normalize(path);
1000
1065
  switch (loc.kind) {
1066
+ case "skillPath": throw eacces("mkdir", norm);
1001
1067
  // Known structural directories already exist — mkdir is a no-op.
1002
1068
  case "root":
1003
1069
  case "projectsDir":
@@ -1044,6 +1110,7 @@ export class SforaFs {
1044
1110
  const loc = classify(path);
1045
1111
  const norm = normalize(path);
1046
1112
  switch (loc.kind) {
1113
+ case "skillPath": throw eacces("unlink", norm);
1047
1114
  case "postFile": {
1048
1115
  const exists = (await this.#matchEntry(loc.slug, loc.dir, loc.filename)) !==
1049
1116
  undefined;
@@ -1249,6 +1316,7 @@ export class SforaFs {
1249
1316
  paths.add(`/projects/${slug}/drafts`);
1250
1317
  paths.add(`/projects/${slug}/docs`);
1251
1318
  paths.add(`/projects/${slug}/artifacts`);
1319
+ paths.add(`/projects/${slug}/skills`);
1252
1320
  paths.add(`/projects/${slug}/library`);
1253
1321
  paths.add(`/projects/${slug}/library/documents`);
1254
1322
  paths.add(`/projects/${slug}/library/files`);
@@ -260,6 +260,12 @@ export interface ChatMessage {
260
260
  _creationTime: number;
261
261
  /** Null when the author's member row is gone. */
262
262
  author: ChatAuthor | null;
263
+ /**
264
+ * The client slug the message was sent through ("claude-code"), when the
265
+ * sending door stamped one. Absent on app sends and on servers that do not
266
+ * surface it yet.
267
+ */
268
+ sentVia?: string;
263
269
  }
264
270
  /** A page of messages, NEWEST FIRST — the server paginates backwards. */
265
271
  export interface MessagesPage {
@@ -297,6 +303,14 @@ export interface SforaApiConfig {
297
303
  * key per agent.
298
304
  */
299
305
  actAs?: string;
306
+ /**
307
+ * What kind of client is on the line — a short slug ("claude-code", "cli").
308
+ * Sent as `X-Sfora-Client` on every request so creation doors can stamp the
309
+ * things they make ("sent via Claude Code" on a message or post). Sent
310
+ * always, including the plain "cli" — a terminal is honest attribution too.
311
+ * The server sanitizes it and ignores it where it means nothing.
312
+ */
313
+ clientLabel?: string;
300
314
  }
301
315
  /** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
302
316
  export declare class SforaApiError extends Error {
@@ -375,6 +389,8 @@ export interface BlockConflict {
375
389
  export declare function blockConflictFrom(error: unknown): BlockConflict | null;
376
390
  /** Options every markdown write door takes. */
377
391
  export interface WriteOptions {
392
+ /** Whole-document revision from readPathSnapshot; rejects concurrent changes. */
393
+ expectedRevision?: number;
378
394
  /**
379
395
  * Write ONE block instead of the whole file — the id from `?view=blocks`.
380
396
  *
@@ -442,6 +458,27 @@ export declare class SforaApiClient {
442
458
  * A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
443
459
  * server's recovery payload; read it with {@link blockConflictFrom}.
444
460
  */
461
+ listSkillDirectories(project: string): Promise<{
462
+ directories: Array<{
463
+ name: string;
464
+ version: number;
465
+ }>;
466
+ }>;
467
+ readSkillManifest(project: string, name: string): Promise<{
468
+ files: Array<{
469
+ path: string;
470
+ sha256: string;
471
+ size: number;
472
+ executable: boolean;
473
+ }>;
474
+ version: number;
475
+ hash: string;
476
+ }>;
477
+ readSkillFile(project: string, name: string, path: string): Promise<Uint8Array>;
478
+ readPathSnapshot(fsPath: string): Promise<{
479
+ content: string;
480
+ revision: number | null;
481
+ }>;
445
482
  writePath(fsPath: string, markdown: string, options?: WriteOptions): Promise<WriteResult & Partial<CardWriteResult>>;
446
483
  /** `DELETE …/(posts|drafts)/:filename.md` — soft-deletes the matched post. */
447
484
  deletePost(projectSlug: string, kind: PostKind, filename: string): Promise<void>;
@@ -550,6 +587,12 @@ export declare class SforaApiClient {
550
587
  doc?: string;
551
588
  project?: string;
552
589
  includeSelf?: boolean;
590
+ /**
591
+ * Deliver message events for the caller's OWN messages too — how a tail
592
+ * hears the same member speaking from the app. Distinct from
593
+ * `includeSelf`, which is about document writes.
594
+ */
595
+ includeOwn?: boolean;
553
596
  signal?: AbortSignal;
554
597
  }): Promise<AgentEventsPage>;
555
598
  /**
@@ -81,14 +81,21 @@ function routeBase(kind) {
81
81
  }
82
82
  /** `?block=<id>` when one was asked for, and nothing at all when it wasn't. */
83
83
  function blockQuery(options) {
84
- return options?.blockId
85
- ? `?block=${encodeURIComponent(options.blockId)}`
86
- : "";
84
+ const query = new URLSearchParams();
85
+ if (options?.blockId)
86
+ query.set("block", options.blockId);
87
+ if (options?.expectedRevision !== undefined) {
88
+ if (!Number.isSafeInteger(options.expectedRevision) || options.expectedRevision < 0)
89
+ throw new Error("Expected revision must be a non-negative integer.");
90
+ query.set("expectedRevision", String(options.expectedRevision));
91
+ }
92
+ return query.size ? `?${query}` : "";
87
93
  }
88
94
  export class SforaApiClient {
89
95
  #baseUrl;
90
96
  #apiKey;
91
97
  #actAs;
98
+ #clientLabel;
92
99
  /**
93
100
  * What the LAST response said about itself, beyond the data it carried.
94
101
  *
@@ -108,6 +115,7 @@ export class SforaApiClient {
108
115
  this.#baseUrl = config.baseUrl.replace(/\/+$/, "");
109
116
  this.#apiKey = config.apiKey;
110
117
  this.#actAs = config.actAs;
118
+ this.#clientLabel = config.clientLabel;
111
119
  }
112
120
  /** What the last response said about itself, consumed. */
113
121
  takeResponseInfo() {
@@ -148,6 +156,8 @@ export class SforaApiClient {
148
156
  headers.Authorization = `Bearer ${this.#apiKey}`;
149
157
  if (this.#actAs)
150
158
  headers["X-Sfora-Act-As"] = this.#actAs;
159
+ if (this.#clientLabel)
160
+ headers["X-Sfora-Client"] = this.#clientLabel;
151
161
  if (body !== undefined)
152
162
  headers["Content-Type"] = contentType;
153
163
  let res;
@@ -307,6 +317,26 @@ export class SforaApiClient {
307
317
  * A 409 from a `?block=` write throws a {@link SforaApiError} carrying the
308
318
  * server's recovery payload; read it with {@link blockConflictFrom}.
309
319
  */
320
+ async listSkillDirectories(project) {
321
+ return this.#json(`/v1/fs/projects/${encodeURIComponent(project)}/skills`);
322
+ }
323
+ async readSkillManifest(project, name) {
324
+ return this.#json(`/v1/fs/projects/${encodeURIComponent(project)}/skills/${encodeURIComponent(name)}`);
325
+ }
326
+ async readSkillFile(project, name, path) {
327
+ const response = await this.#request("GET", `/v1/fs/projects/${encodeURIComponent(project)}/skills/${encodeURIComponent(name)}/${path.split("/").map(encodeURIComponent).join("/")}`);
328
+ return new Uint8Array(await response.arrayBuffer());
329
+ }
330
+ async readPathSnapshot(fsPath) {
331
+ const response = await this.#request("GET", fsRequestPath(fsPath));
332
+ const advertised = response.headers.get("x-sfora-revision");
333
+ const etag = response.headers.get("etag")?.replace(/^W\//, "").replaceAll('"', "");
334
+ const raw = advertised !== null && /^\d+$/.test(advertised)
335
+ ? advertised : etag?.match(/^(\d+)(?:-(?:gzip|br))?$/)?.[1];
336
+ const numeric = raw === undefined ? NaN : Number(raw);
337
+ const revision = Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : null;
338
+ return { content: await response.text(), revision };
339
+ }
310
340
  async writePath(fsPath, markdown, options) {
311
341
  const res = await this.#request("PUT", `${fsRequestPath(fsPath)}${blockQuery(options)}`, markdown);
312
342
  return this.#jsonFrom(res);
@@ -445,16 +475,7 @@ export class SforaApiClient {
445
475
  const slug = encodeURIComponent(projectSlug);
446
476
  // The server slugifies `name` to derive the directory; we send it as a
447
477
  // single-field JSON body to keep the spec consistent with future fields.
448
- const res = await fetch(`${this.#baseUrl}/v1/fs/projects/${slug}/board`, {
449
- method: "POST",
450
- headers: {
451
- Authorization: `Bearer ${this.#apiKey}`,
452
- "Content-Type": "application/json",
453
- },
454
- body: JSON.stringify({ name }),
455
- });
456
- if (!res.ok)
457
- throw await this.#toError(res);
478
+ const res = await this.#request("POST", `/v1/fs/projects/${slug}/board`, JSON.stringify({ name }), undefined, "application/json");
458
479
  return await this.#jsonFrom(res);
459
480
  }
460
481
  /**
@@ -464,16 +485,7 @@ export class SforaApiClient {
464
485
  async renameColumn(projectSlug, columnDir, newName) {
465
486
  const slug = encodeURIComponent(projectSlug);
466
487
  const col = encodeURIComponent(columnDir);
467
- const res = await fetch(`${this.#baseUrl}/v1/fs/projects/${slug}/board/${col}/_rename`, {
468
- method: "POST",
469
- headers: {
470
- Authorization: `Bearer ${this.#apiKey}`,
471
- "Content-Type": "application/json",
472
- },
473
- body: JSON.stringify({ name: newName }),
474
- });
475
- if (!res.ok)
476
- throw await this.#toError(res);
488
+ const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${col}/_rename`, JSON.stringify({ name: newName }), undefined, "application/json");
477
489
  return await this.#jsonFrom(res);
478
490
  }
479
491
  /** `DELETE …/board/:column` — column must be empty. */
@@ -490,31 +502,13 @@ export class SforaApiClient {
490
502
  const slug = encodeURIComponent(projectSlug);
491
503
  const from = encodeURIComponent(fromCol);
492
504
  const file = encodeURIComponent(filename);
493
- const res = await fetch(`${this.#baseUrl}/v1/fs/projects/${slug}/board/${from}/${file}/_move`, {
494
- method: "POST",
495
- headers: {
496
- Authorization: `Bearer ${this.#apiKey}`,
497
- "Content-Type": "application/json",
498
- },
499
- body: JSON.stringify({ toColumn: toCol }),
500
- });
501
- if (!res.ok)
502
- throw await this.#toError(res);
505
+ const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${from}/${file}/_move`, JSON.stringify({ toColumn: toCol }), undefined, "application/json");
503
506
  return await this.#jsonFrom(res);
504
507
  }
505
508
  // ─── Post actions (comment / react) ─────────────────────────────
506
509
  /** `POST /v1/posts/:postId/comments` — add a comment to a post. */
507
510
  async createComment(postId, body) {
508
- const res = await fetch(`${this.#baseUrl}/v1/posts/${encodeURIComponent(postId)}/comments`, {
509
- method: "POST",
510
- headers: {
511
- Authorization: `Bearer ${this.#apiKey}`,
512
- "Content-Type": "application/json",
513
- },
514
- body: JSON.stringify({ body }),
515
- });
516
- if (!res.ok)
517
- throw await this.#toError(res);
511
+ const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/comments`, JSON.stringify({ body }), undefined, "application/json");
518
512
  const data = await this.#jsonFrom(res);
519
513
  return { id: data.comment?._id ?? "" };
520
514
  }
@@ -523,16 +517,7 @@ export class SforaApiClient {
523
517
  * Returns whether the reaction is now on (`reacted: true`) or off.
524
518
  */
525
519
  async reactToPost(postId, emoji) {
526
- const res = await fetch(`${this.#baseUrl}/v1/posts/${encodeURIComponent(postId)}/reactions`, {
527
- method: "POST",
528
- headers: {
529
- Authorization: `Bearer ${this.#apiKey}`,
530
- "Content-Type": "application/json",
531
- },
532
- body: JSON.stringify({ emoji }),
533
- });
534
- if (!res.ok)
535
- throw await this.#toError(res);
520
+ const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/reactions`, JSON.stringify({ emoji }), undefined, "application/json");
536
521
  const data = await this.#jsonFrom(res);
537
522
  return data.reaction ?? { content: emoji, reacted: true };
538
523
  }
@@ -600,6 +585,9 @@ export class SforaApiClient {
600
585
  // own writes, matching the message branch's "don't wake on your own".
601
586
  if (params.includeSelf)
602
587
  query.set("self", "include");
588
+ // `?includeOwn=1` — exactly "1", the server's spelling.
589
+ if (params.includeOwn)
590
+ query.set("includeOwn", "1");
603
591
  return this.#json(`/v1/events?${query.toString()}`, params.signal);
604
592
  }
605
593
  /**
@@ -71,6 +71,7 @@ export declare function blocksCommand(client: SforaApiClient, fsPath: string, op
71
71
  */
72
72
  export declare function putCommand(client: SforaApiClient, fsPath: string, body: string, options?: {
73
73
  blockId?: string;
74
+ expectedRevision?: number;
74
75
  json?: boolean;
75
76
  /**
76
77
  * The run's presence latch. Omitted means "this call is the run" — a
@@ -106,6 +106,7 @@ export async function putCommand(client, fsPath, body, options = {}) {
106
106
  try {
107
107
  const result = await client.writePath(fsPath, body, {
108
108
  blockId: options.blockId,
109
+ ...(options.expectedRevision !== undefined ? { expectedRevision: options.expectedRevision } : {}),
109
110
  });
110
111
  const info = client.takeResponseInfo();
111
112
  if (options.json)
package/dist/chat.d.ts CHANGED
@@ -82,6 +82,14 @@ export declare function renderChatBody(markdown: string): string;
82
82
  * context, not content.
83
83
  */
84
84
  export declare function renderChatMessage(msg: ChatMessage, now: number): string;
85
+ /**
86
+ * One message as an NDJSON line — the machine half of `chat --follow --json`
87
+ * and `--await-reply --json`. A small, stable shape rather than the wire row:
88
+ * `{ author, body, at, sentVia? }`, `at` in ISO 8601. `sentVia` appears only
89
+ * when the sending door stamped one; the author's name is passed through raw —
90
+ * capitalization is a display rule, and this line is for a program.
91
+ */
92
+ export declare function chatMessageJson(msg: ChatMessage): string;
85
93
  /** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
86
94
  export declare function renderRoomList(rooms: Room[]): string;
87
95
  /** Everything the tail loop needs from the outside world. */
@@ -114,3 +122,50 @@ export interface ChatTailOptions {
114
122
  * `seen` set is the single ledger, shared with the prompt's own sends.
115
123
  */
116
124
  export declare function chatTailLoop(deps: ChatTailDeps, options: ChatTailOptions): Promise<number>;
125
+ /** Everything the reply wait needs from the outside world. */
126
+ export interface AwaitReplyDeps {
127
+ /**
128
+ * One `/v1/events` long-poll — the caller's poll passes `includeOwn`, so
129
+ * the same member answering from the app rings the doorbell too.
130
+ */
131
+ poll(since: number): Promise<AgentEventsPage>;
132
+ /** The room's recent page, any order — refetched when the doorbell rings. */
133
+ fetchRecent(): Promise<ChatMessage[]>;
134
+ /** A warning, on stderr — never mixed into stdout. */
135
+ warn(text: string): void;
136
+ sleep(ms: number): Promise<void>;
137
+ /** The clock, injectable for tests. */
138
+ now?(): number;
139
+ }
140
+ export interface AwaitReplyOptions {
141
+ roomId: string;
142
+ /**
143
+ * Message ids that are NOT the reply: the history before the send, and the
144
+ * send itself. The reply is picked by id, not author — the same human
145
+ * answering from the app IS the reply.
146
+ */
147
+ seen: Set<string>;
148
+ /** Events cursor to start from — BEFORE the send, on server time. */
149
+ since: number;
150
+ /** Absolute deadline (ms epoch); absent means wait forever. */
151
+ deadlineMs?: number;
152
+ stopped?: () => boolean;
153
+ backoffMs?: number;
154
+ }
155
+ export type AwaitReplyResult = {
156
+ outcome: "reply";
157
+ message: ChatMessage;
158
+ } | {
159
+ outcome: "timeout";
160
+ } | {
161
+ outcome: "stopped";
162
+ };
163
+ /**
164
+ * Poll until the room speaks back, the deadline passes, or the caller stops.
165
+ *
166
+ * The same rules the tail holds — forward-only cursor, doubling backoff to
167
+ * {@link MAX_BACKOFF_MS}, the doorbell-then-refetch shape — but instead of
168
+ * printing forever, the FIRST unseen message ends the wait. Oldest wins when
169
+ * several land at once: the reply is the next thing said, not the latest.
170
+ */
171
+ export declare function awaitReplyLoop(deps: AwaitReplyDeps, options: AwaitReplyOptions): Promise<AwaitReplyResult>;
package/dist/chat.js CHANGED
@@ -199,6 +199,21 @@ export function renderChatMessage(msg, now) {
199
199
  const header = `${colors.bold}${author}${colors.reset} ${dim(relativeTime(msg._creationTime, now))}`;
200
200
  return `${header}\n${renderChatBody(msg.body)}`;
201
201
  }
202
+ /**
203
+ * One message as an NDJSON line — the machine half of `chat --follow --json`
204
+ * and `--await-reply --json`. A small, stable shape rather than the wire row:
205
+ * `{ author, body, at, sentVia? }`, `at` in ISO 8601. `sentVia` appears only
206
+ * when the sending door stamped one; the author's name is passed through raw —
207
+ * capitalization is a display rule, and this line is for a program.
208
+ */
209
+ export function chatMessageJson(msg) {
210
+ return JSON.stringify({
211
+ author: msg.author?.name ?? null,
212
+ body: msg.body,
213
+ at: new Date(msg._creationTime).toISOString(),
214
+ ...(msg.sentVia ? { sentVia: msg.sentVia } : {}),
215
+ });
216
+ }
202
217
  /** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
203
218
  export function renderRoomList(rooms) {
204
219
  if (rooms.length === 0)
@@ -266,3 +281,63 @@ export async function chatTailLoop(deps, options) {
266
281
  }
267
282
  return cursor;
268
283
  }
284
+ /**
285
+ * Poll until the room speaks back, the deadline passes, or the caller stops.
286
+ *
287
+ * The same rules the tail holds — forward-only cursor, doubling backoff to
288
+ * {@link MAX_BACKOFF_MS}, the doorbell-then-refetch shape — but instead of
289
+ * printing forever, the FIRST unseen message ends the wait. Oldest wins when
290
+ * several land at once: the reply is the next thing said, not the latest.
291
+ */
292
+ export async function awaitReplyLoop(deps, options) {
293
+ const stopped = options.stopped ?? (() => false);
294
+ const now = deps.now ?? Date.now;
295
+ const firstBackoff = options.backoffMs ?? 1_000;
296
+ let cursor = options.since;
297
+ let backoff = firstBackoff;
298
+ while (!stopped()) {
299
+ if (options.deadlineMs !== undefined && now() >= options.deadlineMs) {
300
+ return { outcome: "timeout" };
301
+ }
302
+ let page;
303
+ try {
304
+ page = await deps.poll(cursor);
305
+ }
306
+ catch (error) {
307
+ if (stopped())
308
+ break;
309
+ const message = error instanceof Error ? error.message : String(error);
310
+ deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
311
+ await deps.sleep(backoff);
312
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
313
+ continue;
314
+ }
315
+ backoff = firstBackoff;
316
+ const rang = page.events.some((e) => e.type === "message" && e.roomId === options.roomId);
317
+ if (rang) {
318
+ let recent;
319
+ try {
320
+ recent = await deps.fetchRecent();
321
+ }
322
+ catch (error) {
323
+ const message = error instanceof Error ? error.message : String(error);
324
+ deps.warn(`could not fetch new messages — ${message}`);
325
+ // The doorbell rang and the refetch failed: do NOT advance the
326
+ // cursor, or the reply's event is consumed and the wait can hang
327
+ // forever if nothing else is ever said (reviewer MAJOR). Back off
328
+ // briefly and let the next poll re-deliver the same event.
329
+ await deps.sleep(backoff);
330
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
331
+ continue;
332
+ }
333
+ const fresh = recent
334
+ .filter((m) => !options.seen.has(m._id))
335
+ .sort((a, b) => a._creationTime - b._creationTime);
336
+ if (fresh.length > 0)
337
+ return { outcome: "reply", message: fresh[0] };
338
+ }
339
+ // Never backwards: an empty page holds the cursor where it was.
340
+ cursor = Math.max(cursor, page.cursor);
341
+ }
342
+ return { outcome: "stopped" };
343
+ }
@@ -6,6 +6,10 @@
6
6
  * process — argv in, a parsed shape out.
7
7
  */
8
8
  export interface CliArgs {
9
+ skillsTarget?: string;
10
+ skillVersion?: number;
11
+ expectedVersion?: number;
12
+ expectedRevision?: number;
9
13
  command?: string;
10
14
  rest: string[];
11
15
  org?: string;
@@ -29,6 +33,9 @@ export interface CliArgs {
29
33
  waitFlag: boolean;
30
34
  message?: string;
31
35
  limit?: number;
36
+ follow: boolean;
37
+ awaitReply: boolean;
38
+ timeout?: number;
32
39
  client?: string;
33
40
  option: string[];
34
41
  target?: string;