sfora-cli 0.13.1 → 0.14.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.
@@ -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 {
@@ -550,6 +564,12 @@ export declare class SforaApiClient {
550
564
  doc?: string;
551
565
  project?: string;
552
566
  includeSelf?: boolean;
567
+ /**
568
+ * Deliver message events for the caller's OWN messages too — how a tail
569
+ * hears the same member speaking from the app. Distinct from
570
+ * `includeSelf`, which is about document writes.
571
+ */
572
+ includeOwn?: boolean;
553
573
  signal?: AbortSignal;
554
574
  }): Promise<AgentEventsPage>;
555
575
  /**
@@ -89,6 +89,7 @@ export class SforaApiClient {
89
89
  #baseUrl;
90
90
  #apiKey;
91
91
  #actAs;
92
+ #clientLabel;
92
93
  /**
93
94
  * What the LAST response said about itself, beyond the data it carried.
94
95
  *
@@ -108,6 +109,7 @@ export class SforaApiClient {
108
109
  this.#baseUrl = config.baseUrl.replace(/\/+$/, "");
109
110
  this.#apiKey = config.apiKey;
110
111
  this.#actAs = config.actAs;
112
+ this.#clientLabel = config.clientLabel;
111
113
  }
112
114
  /** What the last response said about itself, consumed. */
113
115
  takeResponseInfo() {
@@ -148,6 +150,8 @@ export class SforaApiClient {
148
150
  headers.Authorization = `Bearer ${this.#apiKey}`;
149
151
  if (this.#actAs)
150
152
  headers["X-Sfora-Act-As"] = this.#actAs;
153
+ if (this.#clientLabel)
154
+ headers["X-Sfora-Client"] = this.#clientLabel;
151
155
  if (body !== undefined)
152
156
  headers["Content-Type"] = contentType;
153
157
  let res;
@@ -445,16 +449,7 @@ export class SforaApiClient {
445
449
  const slug = encodeURIComponent(projectSlug);
446
450
  // The server slugifies `name` to derive the directory; we send it as a
447
451
  // 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);
452
+ const res = await this.#request("POST", `/v1/fs/projects/${slug}/board`, JSON.stringify({ name }), undefined, "application/json");
458
453
  return await this.#jsonFrom(res);
459
454
  }
460
455
  /**
@@ -464,16 +459,7 @@ export class SforaApiClient {
464
459
  async renameColumn(projectSlug, columnDir, newName) {
465
460
  const slug = encodeURIComponent(projectSlug);
466
461
  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);
462
+ const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${col}/_rename`, JSON.stringify({ name: newName }), undefined, "application/json");
477
463
  return await this.#jsonFrom(res);
478
464
  }
479
465
  /** `DELETE …/board/:column` — column must be empty. */
@@ -490,31 +476,13 @@ export class SforaApiClient {
490
476
  const slug = encodeURIComponent(projectSlug);
491
477
  const from = encodeURIComponent(fromCol);
492
478
  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);
479
+ const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${from}/${file}/_move`, JSON.stringify({ toColumn: toCol }), undefined, "application/json");
503
480
  return await this.#jsonFrom(res);
504
481
  }
505
482
  // ─── Post actions (comment / react) ─────────────────────────────
506
483
  /** `POST /v1/posts/:postId/comments` — add a comment to a post. */
507
484
  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);
485
+ const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/comments`, JSON.stringify({ body }), undefined, "application/json");
518
486
  const data = await this.#jsonFrom(res);
519
487
  return { id: data.comment?._id ?? "" };
520
488
  }
@@ -523,16 +491,7 @@ export class SforaApiClient {
523
491
  * Returns whether the reaction is now on (`reacted: true`) or off.
524
492
  */
525
493
  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);
494
+ const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/reactions`, JSON.stringify({ emoji }), undefined, "application/json");
536
495
  const data = await this.#jsonFrom(res);
537
496
  return data.reaction ?? { content: emoji, reacted: true };
538
497
  }
@@ -600,6 +559,9 @@ export class SforaApiClient {
600
559
  // own writes, matching the message branch's "don't wake on your own".
601
560
  if (params.includeSelf)
602
561
  query.set("self", "include");
562
+ // `?includeOwn=1` — exactly "1", the server's spelling.
563
+ if (params.includeOwn)
564
+ query.set("includeOwn", "1");
603
565
  return this.#json(`/v1/events?${query.toString()}`, params.signal);
604
566
  }
605
567
  /**
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
+ }
@@ -29,6 +29,9 @@ export interface CliArgs {
29
29
  waitFlag: boolean;
30
30
  message?: string;
31
31
  limit?: number;
32
+ follow: boolean;
33
+ awaitReply: boolean;
34
+ timeout?: number;
32
35
  client?: string;
33
36
  option: string[];
34
37
  target?: string;
package/dist/cli-args.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * process — argv in, a parsed shape out.
7
7
  */
8
8
  export function parseArgs(argv) {
9
- const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false, waitFlag: false, option: [] };
9
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false, waitFlag: false, follow: false, awaitReply: false, option: [] };
10
10
  for (let i = 0; i < argv.length; i++) {
11
11
  const a = argv[i];
12
12
  if (a === "--mcp")
@@ -89,6 +89,14 @@ export function parseArgs(argv) {
89
89
  args.limit = Number.parseInt(argv[++i] ?? "", 10);
90
90
  else if (a.startsWith("--limit="))
91
91
  args.limit = Number.parseInt(a.slice("--limit=".length), 10);
92
+ else if (a === "--follow")
93
+ args.follow = true;
94
+ else if (a === "--await-reply")
95
+ args.awaitReply = true;
96
+ else if (a === "--timeout")
97
+ args.timeout = Number.parseInt(argv[++i] ?? "", 10);
98
+ else if (a.startsWith("--timeout="))
99
+ args.timeout = Number.parseInt(a.slice("--timeout=".length), 10);
92
100
  else if (a === "--client")
93
101
  args.client = argv[++i];
94
102
  else if (a.startsWith("--client="))
package/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, }
21
21
  import { runMcpServer } from "./mcp-server.js";
22
22
  import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
23
23
  import { parseArgs } from "./cli-args.js";
24
- import { capitalizeName, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
24
+ import { awaitReplyLoop, capitalizeName, chatMessageJson, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
25
25
  const HELP = `sfora — the CLI for your sfora workspace
26
26
 
27
27
  Get started (no account needed):
@@ -69,10 +69,18 @@ Chat:
69
69
  new messages stream in live
70
70
  sfora chat <room> -m "text" Send one message and exit (for scripts
71
71
  and agents)
72
+ sfora chat <room> --follow Tail the room without a prompt — for
73
+ logs and pipes (--json for NDJSON)
74
+ sfora chat <room> -m "text" --await-reply
75
+ Send, then wait for the next message
76
+ back and print it — --timeout <secs>
77
+ stops waiting (exit code 2)
72
78
 
73
79
  Chat shows others what's on the line: the CLI reports itself in presence,
74
80
  and a coding agent is named automatically (Claude Code, Codex, Cursor and
75
81
  Gemini set their own environment). Add --client <name> to say it yourself.
82
+ Everything the CLI creates — messages, posts, tasks, docs — carries the
83
+ same name, so the feed says what sent it.
76
84
 
77
85
  Ask a human:
78
86
  sfora ask "<question>" --option "A" --option "B"
@@ -178,6 +186,16 @@ async function runInit(args) {
178
186
  console.log(`${colors.green}✓${colors.reset} Saved ${path} ${colors.dim}(${profile})${colors.reset}`);
179
187
  }
180
188
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
189
+ /** A backoff sleep that a stop check can cut short: sliced into 250ms
190
+ * intervals so ^C during a 30s reconnect backoff exits promptly instead of
191
+ * waiting the sleep out (reviewer catch — --follow/--await-reply are
192
+ * unattended surfaces where a hung shutdown means a hung supervisor). */
193
+ const stoppableSleep = (stopped) => async (ms) => {
194
+ const until = Date.now() + ms;
195
+ while (Date.now() < until && !stopped()) {
196
+ await sleep(Math.min(250, until - Date.now()));
197
+ }
198
+ };
181
199
  function openBrowser(url) {
182
200
  const { command, args } = openerCommand(process.platform, url);
183
201
  try {
@@ -539,7 +557,7 @@ async function runVerb(args, fs, client) {
539
557
  }
540
558
  if (args.command === "chat") {
541
559
  if (!args.rest[0]) {
542
- throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"]');
560
+ throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"] [--follow] [--await-reply]');
543
561
  }
544
562
  return runChat(args, client);
545
563
  }
@@ -767,7 +785,7 @@ async function runWatch(args, client, target) {
767
785
  write: (text) => process.stdout.write(text),
768
786
  // Warnings on stderr so `--json` stdout stays parseable NDJSON.
769
787
  warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
770
- sleep,
788
+ sleep: stoppableSleep(() => stop),
771
789
  }, { json: args.json, since, stopped: () => stop });
772
790
  }
773
791
  finally {
@@ -829,13 +847,16 @@ async function runChat(args, client) {
829
847
  // is driving — so the app can show the terminal next to the online dot.
830
848
  const clientLabel = detectClient(process.env, args.client);
831
849
  const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
832
- // One-shot send.
850
+ // One-shot send — with --await-reply, the wait for what comes back.
833
851
  if (args.message !== undefined) {
834
852
  const text = args.message.trim();
835
853
  if (!text)
836
854
  throw new Error('usage: sfora chat <room> -m "text"');
837
855
  // One beat alongside the send — a script that speaks was here, briefly.
838
856
  void beat();
857
+ if (args.awaitReply) {
858
+ return runSendAwaitReply(args, client, room, tag, text, clientLabel);
859
+ }
839
860
  const { messageId } = await client.sendRoomMessage(room._id, text, clientLabel);
840
861
  if (args.json) {
841
862
  console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
@@ -844,6 +865,12 @@ async function runChat(args, client) {
844
865
  console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
845
866
  return;
846
867
  }
868
+ if (args.awaitReply) {
869
+ throw new Error('usage: sfora chat <room> -m "text" --await-reply [--timeout <secs>] — the reply needs a message to reply to');
870
+ }
871
+ // The tail without the prompt — for logs, pipes, and agents.
872
+ if (args.follow)
873
+ return runChatFollow(args, client, room, tag, clientLabel);
847
874
  // History, oldest first — the page arrives newest-first.
848
875
  const limit = Number.isFinite(args.limit) && args.limit > 0
849
876
  ? Math.min(args.limit, 100)
@@ -854,6 +881,7 @@ async function runChat(args, client) {
854
881
  const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
855
882
  const seeded = history.page.slice().reverse();
856
883
  const seen = new Set(seeded.map((m) => m._id));
884
+ const pendingSendBodies = new Set();
857
885
  const messages = seeded.slice(-limit);
858
886
  if (messages.length === 0) {
859
887
  console.log(`${colors.dim}(no messages yet)${colors.reset}`);
@@ -899,11 +927,27 @@ async function runChat(args, client) {
899
927
  });
900
928
  const wait = Number.isFinite(args.wait) ? args.wait : undefined;
901
929
  const tail = chatTailLoop({
902
- poll: (since) => client.pollEvents({ since, wait, signal: inFlight.signal }),
930
+ // `includeOwn`: your own app sends ring the doorbell too, so talking in
931
+ // the web while sitting here shows both halves of the conversation. The
932
+ // seen set already keeps the CLI's own sends from printing twice.
933
+ poll: (since) => client.pollEvents({
934
+ since,
935
+ wait,
936
+ includeOwn: true,
937
+ signal: inFlight.signal,
938
+ }),
903
939
  fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
904
- print: (msg) => printAbove(renderChatMessage(msg, Date.now())),
940
+ // With includeOwn on, a send's event can race its own HTTP response:
941
+ // the tail may refetch before `seen.add(messageId)` runs. Bodies in
942
+ // flight are suppressed for that window (the typed line on screen is
943
+ // the echo); the id joins `seen` when the send resolves.
944
+ print: (msg) => {
945
+ if (pendingSendBodies.has(msg.body))
946
+ return;
947
+ printAbove(renderChatMessage(msg, Date.now()));
948
+ },
905
949
  warn: (text) => printAbove(`${colors.dim}${text}${colors.reset}`),
906
- sleep,
950
+ sleep: stoppableSleep(() => stop),
907
951
  }, {
908
952
  roomId: room._id,
909
953
  seen,
@@ -924,6 +968,7 @@ async function runChat(args, client) {
924
968
  }
925
969
  if (line === "/quit" || line === "/exit" || line === "/q")
926
970
  break;
971
+ pendingSendBodies.add(line);
927
972
  try {
928
973
  // The send's own echo is the typed line still on screen; recording the
929
974
  // id keeps the tail's refetch from printing it a second time.
@@ -934,6 +979,9 @@ async function runChat(args, client) {
934
979
  const msg = e instanceof Error ? e.message : String(e);
935
980
  process.stderr.write(`${colors.red}error:${colors.reset} ${msg}\n`);
936
981
  }
982
+ finally {
983
+ pendingSendBodies.delete(line);
984
+ }
937
985
  if (isTty)
938
986
  rl.prompt();
939
987
  }
@@ -948,6 +996,165 @@ async function runChat(args, client) {
948
996
  if (isTty)
949
997
  console.log("");
950
998
  }
999
+ /**
1000
+ * `sfora chat <room> --follow` — the tail without the prompt.
1001
+ *
1002
+ * For logs, pipes, and agents: history first (respecting `-n`), then new
1003
+ * messages as they arrive — no prompt, no TTY needed. The poll asks for the
1004
+ * caller's OWN messages too (`includeOwn`), so the same person talking from
1005
+ * the app appears here; nothing prints twice, the seen set is the ledger.
1006
+ * `--json` emits one NDJSON object per message. ^C exits cleanly.
1007
+ */
1008
+ async function runChatFollow(args, client, room, tag, clientLabel) {
1009
+ const emit = (msg) => console.log(args.json ? chatMessageJson(msg) : renderChatMessage(msg, Date.now()));
1010
+ // History, oldest first — seeded wide enough that the doorbell's refetch
1011
+ // (20) never "discovers" older history, printed only as far as -n asked.
1012
+ const limit = Number.isFinite(args.limit) && args.limit > 0
1013
+ ? Math.min(args.limit, 100)
1014
+ : 30;
1015
+ const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
1016
+ const seeded = history.page.slice().reverse();
1017
+ const seen = new Set(seeded.map((m) => m._id));
1018
+ for (const msg of seeded.slice(-limit))
1019
+ emit(msg);
1020
+ process.stderr.write(`${colors.dim}following ${tag} — ^C to stop${colors.reset}\n`);
1021
+ // Following is being in the room: beat now, then every ~45s, same as the
1022
+ // interactive chat. `unref` so the timer never holds the process open.
1023
+ const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
1024
+ void beat();
1025
+ const pulse = setInterval(() => void beat(), 45_000);
1026
+ pulse.unref?.();
1027
+ // ^C has to reach the socket — the long-poll hangs for tens of seconds.
1028
+ // Second ^C stands down and lets the default behaviour end things.
1029
+ let stop = false;
1030
+ const inFlight = new AbortController();
1031
+ let signalled = false;
1032
+ const onSignal = (signal) => {
1033
+ stop = true;
1034
+ inFlight.abort();
1035
+ if (signalled) {
1036
+ process.off("SIGINT", onSignal);
1037
+ process.off("SIGTERM", onSignal);
1038
+ process.kill(process.pid, signal);
1039
+ return;
1040
+ }
1041
+ signalled = true;
1042
+ };
1043
+ process.on("SIGINT", onSignal);
1044
+ process.on("SIGTERM", onSignal);
1045
+ const wait = Number.isFinite(args.wait) ? args.wait : undefined;
1046
+ try {
1047
+ await chatTailLoop({
1048
+ poll: (since) => client.pollEvents({
1049
+ since,
1050
+ wait,
1051
+ includeOwn: true,
1052
+ signal: inFlight.signal,
1053
+ }),
1054
+ fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
1055
+ print: emit,
1056
+ warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
1057
+ sleep: stoppableSleep(() => stop),
1058
+ }, {
1059
+ roomId: room._id,
1060
+ seen,
1061
+ // Cursor on SERVER time — see the interactive tail.
1062
+ since: history.page[0]?._creationTime ?? Date.now(),
1063
+ stopped: () => stop,
1064
+ });
1065
+ }
1066
+ finally {
1067
+ clearInterval(pulse);
1068
+ process.off("SIGINT", onSignal);
1069
+ process.off("SIGTERM", onSignal);
1070
+ }
1071
+ // A parting beat, best-effort — awaited so exit doesn't race it.
1072
+ await beat();
1073
+ }
1074
+ /**
1075
+ * `sfora chat <room> -m "…" --await-reply` — send, then hold the line.
1076
+ *
1077
+ * Blocks until the NEXT message that is not the send itself lands in the
1078
+ * room, prints it, and exits 0 — the reply is picked by message id, not
1079
+ * author, so the same human answering from the app counts. `--timeout <secs>`
1080
+ * gives up with exit code 2. Everything already said before the send is
1081
+ * seeded as seen, so an old message can never pose as the answer.
1082
+ */
1083
+ async function runSendAwaitReply(args, client, room, tag, text, clientLabel) {
1084
+ // Seed BEFORE the send: the history is not the reply, and the events cursor
1085
+ // is server time, so a fast local clock opens no blind window.
1086
+ const history = await client.listRoomMessages(room._id, 50);
1087
+ const seen = new Set(history.page.map((m) => m._id));
1088
+ const since = history.page[0]?._creationTime ?? Date.now();
1089
+ // A mistyped --timeout must fail loudly, not silently wait forever — the
1090
+ // exact opposite of what the flag asked for (reviewer catch).
1091
+ if (args.timeout !== undefined && (!Number.isFinite(args.timeout) || args.timeout <= 0)) {
1092
+ throw new Error("--timeout needs a positive number of seconds");
1093
+ }
1094
+ const { messageId } = await client.sendRoomMessage(room._id, text, clientLabel);
1095
+ seen.add(messageId);
1096
+ const deadlineMs = args.timeout !== undefined ? Date.now() + args.timeout * 1000 : undefined;
1097
+ if (args.json) {
1098
+ // NDJSON-shaped: this line says it landed; the reply is the next line.
1099
+ console.log(JSON.stringify({ messageId, roomId: room._id }));
1100
+ }
1101
+ else {
1102
+ console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
1103
+ process.stderr.write(`${colors.dim}waiting for a reply${deadlineMs ? ` (up to ${args.timeout}s)` : ""} — ^C to stop${colors.reset}\n`);
1104
+ }
1105
+ // Same signal shape as `ask --wait`: ^C reaches the socket, second ^C
1106
+ // stands down and lets the default behaviour end things.
1107
+ let stop = false;
1108
+ const inFlight = new AbortController();
1109
+ let signalled = false;
1110
+ const onSignal = (signal) => {
1111
+ stop = true;
1112
+ inFlight.abort();
1113
+ if (signalled) {
1114
+ process.off("SIGINT", onSignal);
1115
+ process.off("SIGTERM", onSignal);
1116
+ process.kill(process.pid, signal);
1117
+ return;
1118
+ }
1119
+ signalled = true;
1120
+ };
1121
+ process.on("SIGINT", onSignal);
1122
+ process.on("SIGTERM", onSignal);
1123
+ try {
1124
+ const result = await awaitReplyLoop({
1125
+ // `includeOwn`: the same member answering from the app must ring the
1126
+ // doorbell — the reply filter is by message id, not author. With a
1127
+ // deadline, each poll's server-side budget is capped to what is left.
1128
+ poll: (cursor) => client.pollEvents({
1129
+ since: cursor,
1130
+ wait: deadlineMs
1131
+ ? Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))
1132
+ : undefined,
1133
+ includeOwn: true,
1134
+ signal: inFlight.signal,
1135
+ }),
1136
+ fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
1137
+ warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
1138
+ sleep: stoppableSleep(() => stop),
1139
+ }, { roomId: room._id, seen, since, deadlineMs, stopped: () => stop });
1140
+ if (result.outcome === "reply") {
1141
+ console.log(args.json
1142
+ ? chatMessageJson(result.message)
1143
+ : renderChatMessage(result.message, Date.now()));
1144
+ }
1145
+ else if (result.outcome === "timeout") {
1146
+ process.stderr.write(`${colors.dim}no reply within ${args.timeout}s${colors.reset}\n`);
1147
+ // 2, not 1: "nobody answered" is its own branch for a script — distinct
1148
+ // from "the send failed", which throws and exits 1.
1149
+ process.exitCode = 2;
1150
+ }
1151
+ // stopped (^C): say nothing — the message is sent and stands.
1152
+ }
1153
+ finally {
1154
+ process.off("SIGINT", onSignal);
1155
+ process.off("SIGTERM", onSignal);
1156
+ }
1157
+ }
951
1158
  /**
952
1159
  * `sfora ask` — how an agent asks a human, wired to the real process.
953
1160
  *
@@ -1063,7 +1270,7 @@ async function runAsk(args, client) {
1063
1270
  signal: inFlight.signal,
1064
1271
  }),
1065
1272
  warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
1066
- sleep,
1273
+ sleep: stoppableSleep(() => stop),
1067
1274
  }, { askId: created.askId, since, deadlineMs, stopped: () => stop });
1068
1275
  if (result.outcome === "answered") {
1069
1276
  const answer = result.chosenOption ?? result.resolution;
@@ -1304,6 +1511,12 @@ async function main() {
1304
1511
  const cfg = await readConfig();
1305
1512
  const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org, bot: args.bot }, cfg);
1306
1513
  const { url: baseUrl, apiKey } = settings;
1514
+ // Once per invocation: what kind of client is on the line. The api client
1515
+ // sends it on every request (`X-Sfora-Client`), so every creation door
1516
+ // stamps its work — a post made from Claude Code says so on the feed, the
1517
+ // same way a chat message does. Sent always, "cli" included: a plain
1518
+ // terminal is honest attribution too.
1519
+ const clientLabel = detectClient(process.env, args.client);
1307
1520
  if (args.command === "mcp-config") {
1308
1521
  printMcpConfig(settings);
1309
1522
  return;
@@ -1344,6 +1557,7 @@ async function main() {
1344
1557
  apiKey: apiKey ?? "",
1345
1558
  org: settings.org ?? "",
1346
1559
  localRoot,
1560
+ clientLabel,
1347
1561
  });
1348
1562
  return;
1349
1563
  }
@@ -1372,6 +1586,7 @@ async function main() {
1372
1586
  apiKey,
1373
1587
  org: settings.org ?? "",
1374
1588
  actAs: args.as,
1589
+ clientLabel,
1375
1590
  presence: runPresence,
1376
1591
  });
1377
1592
  try {
@@ -1392,7 +1607,7 @@ async function main() {
1392
1607
  }
1393
1608
  // MCP mode: stdout is the protocol channel — never write logs there.
1394
1609
  if (args.mcp) {
1395
- await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "" });
1610
+ await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "", clientLabel });
1396
1611
  return;
1397
1612
  }
1398
1613
  if (!settings.org) {
@@ -1406,6 +1621,7 @@ async function main() {
1406
1621
  apiKey,
1407
1622
  org,
1408
1623
  cwd: args.cwd,
1624
+ clientLabel,
1409
1625
  presence: runPresence,
1410
1626
  });
1411
1627
  // Pre-flight: confirm auth + connectivity and greet with the resolved identity.
package/dist/index.d.ts CHANGED
@@ -25,6 +25,12 @@ export interface CreateSforaShellOptions {
25
25
  cwd?: string;
26
26
  /** Act/post as an owned agent, using this key (sent as `X-Sfora-Act-As`). */
27
27
  actAs?: string;
28
+ /**
29
+ * What kind of client is on the line ("claude-code", "cli"). Sent as
30
+ * `X-Sfora-Client` on every request so writes are stamped with the terminal
31
+ * that made them — the CLI passes `detectClient(process.env, --client)`.
32
+ */
33
+ clientLabel?: string;
28
34
  /**
29
35
  * The run's "you are visible" latch. Pass one when something OUTSIDE this
30
36
  * shell can print the note too — the CLI does, after any line that wrote —
@@ -69,5 +75,5 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, t
69
75
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
70
76
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type WatchOptions, type WatchTarget, } from "./watch.js";
71
77
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
72
- export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
78
+ export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, type ChatTailDeps, type ChatTailOptions, type AwaitReplyDeps, type AwaitReplyOptions, type AwaitReplyResult, } from "./chat.js";
73
79
  export { validateAskOptions, reshapeCandidatesError, askWaitLoop, ASK_OPTIONS_MIN, ASK_OPTIONS_MAX, ASK_OPTION_MAX_LENGTH, type AskWaitDeps, type AskWaitOptions, type AskWaitResult, } from "./ask.js";
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export function createSforaShell(options) {
17
17
  baseUrl: options.baseUrl,
18
18
  apiKey: options.apiKey,
19
19
  actAs: options.actAs,
20
+ clientLabel: options.clientLabel,
20
21
  });
21
22
  const presence = options.presence ?? presenceNotice();
22
23
  const fs = new SforaFs(client);
@@ -57,5 +58,5 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, }
57
58
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
58
59
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
59
60
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
60
- export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
61
+ export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, } from "./chat.js";
61
62
  export { validateAskOptions, reshapeCandidatesError, askWaitLoop, ASK_OPTIONS_MIN, ASK_OPTIONS_MAX, ASK_OPTION_MAX_LENGTH, } from "./ask.js";
@@ -12,5 +12,7 @@ export interface RunMcpServerOptions {
12
12
  org: string;
13
13
  /** Absolute path of a local `.sfora/` workspace — serves it instead of the cloud. */
14
14
  localRoot?: string;
15
+ /** Terminal client slug for write attribution (`X-Sfora-Client`). */
16
+ clientLabel?: string;
15
17
  }
16
18
  export declare function runMcpServer(options: RunMcpServerOptions): Promise<void>;
@@ -39,6 +39,7 @@ export async function runMcpServer(options) {
39
39
  baseUrl: options.baseUrl,
40
40
  apiKey: options.apiKey,
41
41
  org: options.org,
42
+ clientLabel: options.clientLabel,
42
43
  });
43
44
  // Persistent shell state across tool calls.
44
45
  let cwd = "/";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
6
6
  "keywords": [