sfora-cli 0.11.0 → 0.12.1

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
@@ -328,6 +328,41 @@ silent would look connected and be deaf.
328
328
 
329
329
  Warnings (reconnects) go to stderr, so `--json` stdout stays parseable.
330
330
 
331
+ ## Where — who's in which document
332
+
333
+ `sfora watch` tells you when a document moves. `sfora where` tells you where
334
+ people *are* — the reverse of the presence roster, and the answer to "the doc
335
+ I'm looking at" when nobody sent a link.
336
+
337
+ ```bash
338
+ $ sfora where
339
+ Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
340
+ Dogfood Bot is editing test-document.md (block k7f3a2cx) — https://www.sfora.ai/org/acme/notes/k17e8c0...
341
+
342
+ $ sfora where Thijs
343
+ Thijs is editing test-document.md — https://www.sfora.ai/org/acme/notes/k17e8c0...
344
+
345
+ $ sfora where --json | jq -r 'select(.type == "human") | .path'
346
+ ```
347
+
348
+ * The name is optional and takes a member name, an id, or `self`. A name nobody
349
+ in the workspace answers to is an error, not an empty list — "who?" and
350
+ "nowhere" are different answers.
351
+ * The block id appears only when somebody claimed one. Agents address blocks
352
+ natively; humans are present at document level today.
353
+ * **Asking declares nothing.** Unlike `watch` and every write, `where` is a
354
+ plain `GET` — running it never puts you in a document.
355
+ * You see what your key can open, and nothing else: presence carries a title
356
+ and a link, so a document you're not on the project for never appears.
357
+ * `--as <agent>` composes — the answer is then that agent's view.
358
+ * `--json` writes **NDJSON**, one record per person-in-a-document (flat, so
359
+ each line stands alone): `memberId`, `name`, `type`, `kind`, `block`,
360
+ `lastSeenAt`, `ttlSeconds`, `docId`, `title`, `filename`, `path`, `project`,
361
+ `url`.
362
+
363
+ An entry is live while `lastSeenAt` is inside `ttlSeconds` (90) — the server
364
+ filters on it before answering, so nothing stale comes back.
365
+
331
366
  ## Links — `sfora url` · `sfora open`
332
367
 
333
368
  Every workspace thing has a page. The server says where; the CLI prints it.
@@ -193,6 +193,80 @@ export interface DocPresence {
193
193
  blockResolved: boolean | null;
194
194
  here: DocPresenceMember[];
195
195
  }
196
+ /** One occupant of a document, as `GET /v1/presence` reports them. */
197
+ export interface PresenceOccupant {
198
+ memberId: string;
199
+ name: string;
200
+ type: "human" | "agent";
201
+ kind: "viewing" | "editing";
202
+ /** The block they have claimed, or `null` for the document at large. */
203
+ block: string | null;
204
+ lastSeenAt: number;
205
+ }
206
+ /** One document somebody is in, with everyone who is in it. */
207
+ export interface PresenceDocument {
208
+ docId: string;
209
+ title: string;
210
+ filename: string;
211
+ /** The fs path — what `sfora cat` and `sfora put` take. */
212
+ path: string;
213
+ project: {
214
+ slug: string;
215
+ name: string;
216
+ };
217
+ url: string;
218
+ here: PresenceOccupant[];
219
+ }
220
+ /**
221
+ * `GET /v1/presence` — where everybody is, grouped by document.
222
+ *
223
+ * `ttlSeconds` is the server's own definition of "still here": an entry is
224
+ * live while `Date.now() - lastSeenAt` is inside it. Stated rather than
225
+ * assumed, so a consumer never has to hardcode the heartbeat window.
226
+ */
227
+ export interface PresenceView {
228
+ ttlSeconds: number;
229
+ /** The member asked about, when one was named; `null` for everybody. */
230
+ member: {
231
+ memberId: string;
232
+ name: string;
233
+ type: "human" | "agent";
234
+ } | null;
235
+ documents: PresenceDocument[];
236
+ }
237
+ /**
238
+ * One room, as `GET /api/rooms` lists it. `joined: false` rows appear only
239
+ * with `?all=1` — open rooms the caller can discover and self-join.
240
+ */
241
+ export interface Room {
242
+ _id: string;
243
+ name: string;
244
+ type: string;
245
+ description?: string | null;
246
+ /** The owning project's id (not slug), or null for a free-standing room. */
247
+ projectId: string | null;
248
+ joined: boolean;
249
+ }
250
+ /** Who wrote a chat message. Humans and agents are the same member model. */
251
+ export interface ChatAuthor {
252
+ _id: string;
253
+ name: string;
254
+ type: "human" | "agent";
255
+ }
256
+ /** One message, as a row of `GET /api/rooms/:id/messages`. Body is markdown. */
257
+ export interface ChatMessage {
258
+ _id: string;
259
+ body: string;
260
+ _creationTime: number;
261
+ /** Null when the author's member row is gone. */
262
+ author: ChatAuthor | null;
263
+ }
264
+ /** A page of messages, NEWEST FIRST — the server paginates backwards. */
265
+ export interface MessagesPage {
266
+ page: ChatMessage[];
267
+ continueCursor: string;
268
+ isDone: boolean;
269
+ }
196
270
  /**
197
271
  * One event off `/v1/events`.
198
272
  *
@@ -449,6 +523,17 @@ export declare class SforaApiClient {
449
523
  block?: string;
450
524
  leave?: boolean;
451
525
  }): Promise<DocPresence | null>;
526
+ /**
527
+ * `GET /v1/presence` — where everybody is right now.
528
+ *
529
+ * The reverse of {@link declarePresence}, and a pure read: asking never
530
+ * enrols the asker in anything. `member` takes an id, a name, or `self`.
531
+ *
532
+ * Documents the key cannot open are absent from the answer — the server
533
+ * filters, the CLI prints. That is why this returns everything it is given
534
+ * rather than filtering again here.
535
+ */
536
+ listPresence(member?: string): Promise<PresenceView>;
452
537
  /**
453
538
  * `GET /v1/events` — the long-poll. Blocks server-side until something
454
539
  * happens or the wait budget elapses, then answers with a cursor to poll
@@ -475,6 +560,34 @@ export declare class SforaApiClient {
475
560
  * projection is the server saying which row these bytes are.
476
561
  */
477
562
  resolveDocId(fsPath: string): Promise<string | null>;
563
+ /**
564
+ * `GET /api/rooms` — the caller's rooms. `all` adds open-but-unjoined rooms
565
+ * (`joined: false`), so the room can be found before it is joined.
566
+ */
567
+ listRooms(all?: boolean): Promise<Room[]>;
568
+ /** `POST /api/rooms/:id/join` — self-join an open room. Idempotent. */
569
+ joinRoom(roomId: string): Promise<{
570
+ joined: boolean;
571
+ already: boolean;
572
+ }>;
573
+ /** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
574
+ listRoomMessages(roomId: string, limit?: number): Promise<MessagesPage>;
575
+ /** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
576
+ sendRoomMessage(roomId: string, body: string): Promise<{
577
+ messageId: string;
578
+ }>;
579
+ /**
580
+ * `POST /api/presence` with `{ roomId?, client? }` — one presence beat.
581
+ *
582
+ * `client` is a short slug ("cli", "claude-code") that names what kind of
583
+ * client is on the line; the app reads it out next to the online dot. The
584
+ * server sanitizes and owns the field on every beat, so a beat without a
585
+ * label also clears a stale one.
586
+ */
587
+ heartbeat(params?: {
588
+ roomId?: string;
589
+ client?: string;
590
+ }): Promise<void>;
478
591
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
479
592
  readInbox(): Promise<string>;
480
593
  /** `GET /v1/fs/me/api-key` — text identity (no key material). */
@@ -141,13 +141,15 @@ export class SforaApiClient {
141
141
  * where "stop" has to mean the socket and not just a flag the caller will
142
142
  * read after it returns.
143
143
  */
144
- signal) {
144
+ signal,
145
+ /** The fs surface speaks markdown; the /api chat endpoints speak JSON. */
146
+ contentType = "text/markdown") {
145
147
  const headers = Object.create(null);
146
148
  headers.Authorization = `Bearer ${this.#apiKey}`;
147
149
  if (this.#actAs)
148
150
  headers["X-Sfora-Act-As"] = this.#actAs;
149
151
  if (body !== undefined)
150
- headers["Content-Type"] = "text/markdown";
152
+ headers["Content-Type"] = contentType;
151
153
  let res;
152
154
  try {
153
155
  res = await fetch(`${this.#baseUrl}${path}`, {
@@ -562,6 +564,20 @@ export class SforaApiClient {
562
564
  throw error;
563
565
  }
564
566
  }
567
+ /**
568
+ * `GET /v1/presence` — where everybody is right now.
569
+ *
570
+ * The reverse of {@link declarePresence}, and a pure read: asking never
571
+ * enrols the asker in anything. `member` takes an id, a name, or `self`.
572
+ *
573
+ * Documents the key cannot open are absent from the answer — the server
574
+ * filters, the CLI prints. That is why this returns everything it is given
575
+ * rather than filtering again here.
576
+ */
577
+ async listPresence(member) {
578
+ const query = member ? `?member=${encodeURIComponent(member)}` : "";
579
+ return this.#json(`/v1/presence${query}`);
580
+ }
565
581
  /**
566
582
  * `GET /v1/events` — the long-poll. Blocks server-side until something
567
583
  * happens or the wait budget elapses, then answers with a cursor to poll
@@ -597,6 +613,41 @@ export class SforaApiClient {
597
613
  const view = await this.readBlocks(fsPath);
598
614
  return view.document?.id ?? null;
599
615
  }
616
+ // ─── Chat (rooms + messages) ─────────────────────────────────────
617
+ /**
618
+ * `GET /api/rooms` — the caller's rooms. `all` adds open-but-unjoined rooms
619
+ * (`joined: false`), so the room can be found before it is joined.
620
+ */
621
+ async listRooms(all = false) {
622
+ return this.#json(`/api/rooms${all ? "?all=1" : ""}`);
623
+ }
624
+ /** `POST /api/rooms/:id/join` — self-join an open room. Idempotent. */
625
+ async joinRoom(roomId) {
626
+ const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/join`);
627
+ return this.#jsonFrom(res);
628
+ }
629
+ /** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
630
+ async listRoomMessages(roomId, limit = 30) {
631
+ return this.#json(`/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`);
632
+ }
633
+ /** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
634
+ async sendRoomMessage(roomId, body) {
635
+ // Through #request like every other call, so network failures surface as
636
+ // SforaApiError(0, "network_error") and `--as` rides the shared headers.
637
+ const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/messages`, JSON.stringify({ body }), undefined, "application/json");
638
+ return this.#jsonFrom(res);
639
+ }
640
+ /**
641
+ * `POST /api/presence` with `{ roomId?, client? }` — one presence beat.
642
+ *
643
+ * `client` is a short slug ("cli", "claude-code") that names what kind of
644
+ * client is on the line; the app reads it out next to the online dot. The
645
+ * server sanitizes and owns the field on every beat, so a beat without a
646
+ * label also clears a stale one.
647
+ */
648
+ async heartbeat(params = {}) {
649
+ await this.#request("POST", "/api/presence", JSON.stringify(params), undefined, "application/json");
650
+ }
600
651
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
601
652
  async readInbox() {
602
653
  return this.#text("/v1/fs/inbox/mentions.md");
package/dist/chat.d.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * `sfora rooms` / `sfora join` / `sfora chat` — the room in the terminal.
3
+ *
4
+ * Humans and agents are the same member model in the same rooms, and this is
5
+ * how either sits in the conversation from a terminal. Everything
6
+ * decision-shaped lives here as pure functions (name resolution, message
7
+ * formatting, the tail loop) so it can be tested with canned data; `cli.ts`
8
+ * owns the process — readline, signals, the real streams.
9
+ *
10
+ * The live tail rides `/v1/events`, the same long-poll `sfora watch` uses: the
11
+ * feed carries a `message` event for every room the caller is in (never their
12
+ * own posts), so a new message is a wake, not a poll schedule. The event's
13
+ * preview is clipped server-side at 280 chars, so the loop treats it as a
14
+ * DOORBELL only — on any message event for this room it refetches the recent
15
+ * page and prints what it has not printed yet, deduped by message id. That is
16
+ * also what makes the caller's own sends (echoed by the send itself) never
17
+ * print twice.
18
+ */
19
+ import type { AgentEventsPage, ChatMessage, Room } from "./api-client.js";
20
+ /**
21
+ * A presence client label as the server stores it: lowercase, `[a-z0-9-]`
22
+ * only, at most 32 chars. Anything that sanitizes to nothing is nothing —
23
+ * the caller falls through to its next guess.
24
+ */
25
+ export declare function sanitizeClientSlug(raw: string | undefined): string | undefined;
26
+ /**
27
+ * What kind of client is on the line, for the presence label.
28
+ *
29
+ * Order: an explicit `--client` flag wins; then `AI_AGENT` (the part before
30
+ * the first underscore — `claude-code_2-1-233_agent` names "claude-code");
31
+ * then `CLAUDE_CODE_ENTRYPOINT` being set at all means Claude Code is
32
+ * driving; else this is a plain terminal — "cli". Env is passed in, not
33
+ * read, so every rung is testable.
34
+ */
35
+ export declare function detectClient(env: Record<string, string | undefined>, flag?: string): string;
36
+ /** How `sfora rooms` and `sfora join` spell a room name as an argument. */
37
+ export declare function roomSlug(name: string): string;
38
+ export type RoomMatch = {
39
+ kind: "match";
40
+ room: Room;
41
+ } | {
42
+ kind: "ambiguous";
43
+ candidates: Room[];
44
+ } | {
45
+ kind: "none";
46
+ };
47
+ /**
48
+ * The room a reference names: exact name or slug first (case-insensitive),
49
+ * then an unambiguous prefix of either. Ambiguity is reported, not guessed —
50
+ * sending a message into the wrong room is the failure this exists to prevent.
51
+ * A joined room beats an unjoined one on an otherwise-tied exact match.
52
+ */
53
+ export declare function resolveRoomRef(rooms: Room[], ref: string): RoomMatch;
54
+ /** House rule: a displayed name leads with a capital, wherever it renders. */
55
+ export declare function capitalizeName(name: string): string;
56
+ /**
57
+ * A chat timestamp, relative — a room is read as a conversation, and "3m ago"
58
+ * is how a conversation tells time. Beyond a week the date says it better.
59
+ */
60
+ export declare function relativeTime(ts: number, now: number): string;
61
+ /**
62
+ * A message body for a terminal: markdown, lightly dressed. Headings read
63
+ * bold, fence delimiters and blockquote markers go dim; everything else is
64
+ * printed as the author wrote it — the CLI prints what the server sends, and
65
+ * markdown is already a terminal-legible format.
66
+ */
67
+ export declare function renderChatBody(markdown: string): string;
68
+ /**
69
+ * One message: `Author 3m ago`, then the body indented under it. The author
70
+ * is bold because it is the line's anchor; the time is dim because it is
71
+ * context, not content.
72
+ */
73
+ export declare function renderChatMessage(msg: ChatMessage, now: number): string;
74
+ /** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
75
+ export declare function renderRoomList(rooms: Room[]): string;
76
+ /** Everything the tail loop needs from the outside world. */
77
+ export interface ChatTailDeps {
78
+ /** One `/v1/events` long-poll. Rejects on a drop — the loop backs off. */
79
+ poll(since: number): Promise<AgentEventsPage>;
80
+ /** The room's recent page, any order — refetched when the doorbell rings. */
81
+ fetchRecent(): Promise<ChatMessage[]>;
82
+ /** Print one message the reader has not seen. */
83
+ print(message: ChatMessage): void;
84
+ /** A warning, never mixed into the transcript. */
85
+ warn(text: string): void;
86
+ sleep(ms: number): Promise<void>;
87
+ }
88
+ export interface ChatTailOptions {
89
+ roomId: string;
90
+ /** Message ids already on screen — history, and the caller's own sends. */
91
+ seen: Set<string>;
92
+ /** Events cursor to start from (the CLI starts at "now"). */
93
+ since: number;
94
+ stopped?: () => boolean;
95
+ backoffMs?: number;
96
+ }
97
+ /**
98
+ * Poll until stopped, printing new messages in this room as they arrive.
99
+ *
100
+ * The same three rules `watchLoop` holds, held here for the same reasons: the
101
+ * cursor survives reconnects and only moves forward, drops back off (doubling
102
+ * to {@link MAX_BACKOFF_MS}) and recover fast, and nothing prints twice — the
103
+ * `seen` set is the single ledger, shared with the prompt's own sends.
104
+ */
105
+ export declare function chatTailLoop(deps: ChatTailDeps, options: ChatTailOptions): Promise<number>;
package/dist/chat.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `sfora rooms` / `sfora join` / `sfora chat` — the room in the terminal.
3
+ *
4
+ * Humans and agents are the same member model in the same rooms, and this is
5
+ * how either sits in the conversation from a terminal. Everything
6
+ * decision-shaped lives here as pure functions (name resolution, message
7
+ * formatting, the tail loop) so it can be tested with canned data; `cli.ts`
8
+ * owns the process — readline, signals, the real streams.
9
+ *
10
+ * The live tail rides `/v1/events`, the same long-poll `sfora watch` uses: the
11
+ * feed carries a `message` event for every room the caller is in (never their
12
+ * own posts), so a new message is a wake, not a poll schedule. The event's
13
+ * preview is clipped server-side at 280 chars, so the loop treats it as a
14
+ * DOORBELL only — on any message event for this room it refetches the recent
15
+ * page and prints what it has not printed yet, deduped by message id. That is
16
+ * also what makes the caller's own sends (echoed by the send itself) never
17
+ * print twice.
18
+ */
19
+ import { colors } from "./render.js";
20
+ import { MAX_BACKOFF_MS } from "./watch.js";
21
+ const dim = (text) => `${colors.dim}${text}${colors.reset}`;
22
+ // ─── Client identity (presence label) ────────────────────────────────
23
+ /**
24
+ * A presence client label as the server stores it: lowercase, `[a-z0-9-]`
25
+ * only, at most 32 chars. Anything that sanitizes to nothing is nothing —
26
+ * the caller falls through to its next guess.
27
+ */
28
+ export function sanitizeClientSlug(raw) {
29
+ if (!raw)
30
+ return undefined;
31
+ const slug = raw.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 32);
32
+ return slug || undefined;
33
+ }
34
+ /**
35
+ * What kind of client is on the line, for the presence label.
36
+ *
37
+ * Order: an explicit `--client` flag wins; then `AI_AGENT` (the part before
38
+ * the first underscore — `claude-code_2-1-233_agent` names "claude-code");
39
+ * then `CLAUDE_CODE_ENTRYPOINT` being set at all means Claude Code is
40
+ * driving; else this is a plain terminal — "cli". Env is passed in, not
41
+ * read, so every rung is testable.
42
+ */
43
+ export function detectClient(env, flag) {
44
+ const fromFlag = sanitizeClientSlug(flag);
45
+ if (fromFlag)
46
+ return fromFlag;
47
+ const fromAgent = sanitizeClientSlug(env.AI_AGENT?.split("_")[0]);
48
+ if (fromAgent)
49
+ return fromAgent;
50
+ if (env.CLAUDE_CODE_ENTRYPOINT !== undefined)
51
+ return "claude-code";
52
+ return "cli";
53
+ }
54
+ // ─── Room resolution ─────────────────────────────────────────────────
55
+ /** How `sfora rooms` and `sfora join` spell a room name as an argument. */
56
+ export function roomSlug(name) {
57
+ return name
58
+ .toLowerCase()
59
+ .replace(/[^a-z0-9]+/g, "-")
60
+ .replace(/^-|-$/g, "");
61
+ }
62
+ /**
63
+ * The room a reference names: exact name or slug first (case-insensitive),
64
+ * then an unambiguous prefix of either. Ambiguity is reported, not guessed —
65
+ * sending a message into the wrong room is the failure this exists to prevent.
66
+ * A joined room beats an unjoined one on an otherwise-tied exact match.
67
+ */
68
+ export function resolveRoomRef(rooms, ref) {
69
+ const want = ref.trim().toLowerCase().replace(/^#/, "");
70
+ if (!want)
71
+ return { kind: "none" };
72
+ const bySlug = roomSlug(want);
73
+ const exact = rooms.filter((r) => r.name.toLowerCase() === want || roomSlug(r.name) === bySlug);
74
+ if (exact.length === 1)
75
+ return { kind: "match", room: exact[0] };
76
+ if (exact.length > 1) {
77
+ const joined = exact.filter((r) => r.joined);
78
+ if (joined.length === 1)
79
+ return { kind: "match", room: joined[0] };
80
+ return { kind: "ambiguous", candidates: exact };
81
+ }
82
+ const prefix = rooms.filter((r) => r.name.toLowerCase().startsWith(want) ||
83
+ roomSlug(r.name).startsWith(bySlug));
84
+ if (prefix.length === 1)
85
+ return { kind: "match", room: prefix[0] };
86
+ if (prefix.length > 1)
87
+ return { kind: "ambiguous", candidates: prefix };
88
+ return { kind: "none" };
89
+ }
90
+ // ─── Message formatting ──────────────────────────────────────────────
91
+ /** House rule: a displayed name leads with a capital, wherever it renders. */
92
+ export function capitalizeName(name) {
93
+ return name ? name.charAt(0).toUpperCase() + name.slice(1) : name;
94
+ }
95
+ /**
96
+ * A chat timestamp, relative — a room is read as a conversation, and "3m ago"
97
+ * is how a conversation tells time. Beyond a week the date says it better.
98
+ */
99
+ export function relativeTime(ts, now) {
100
+ const diff = Math.max(0, now - ts);
101
+ const minutes = Math.floor(diff / 60_000);
102
+ if (minutes < 1)
103
+ return "just now";
104
+ if (minutes < 60)
105
+ return `${minutes}m ago`;
106
+ const hours = Math.floor(minutes / 60);
107
+ if (hours < 24)
108
+ return `${hours}h ago`;
109
+ const days = Math.floor(hours / 24);
110
+ if (days < 7)
111
+ return `${days}d ago`;
112
+ // The reader's LOCAL date — toISOString would name yesterday for an
113
+ // evening timestamp anywhere east of UTC.
114
+ const d = new Date(ts);
115
+ const pad = (n) => String(n).padStart(2, "0");
116
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
117
+ }
118
+ /**
119
+ * A message body for a terminal: markdown, lightly dressed. Headings read
120
+ * bold, fence delimiters and blockquote markers go dim; everything else is
121
+ * printed as the author wrote it — the CLI prints what the server sends, and
122
+ * markdown is already a terminal-legible format.
123
+ */
124
+ export function renderChatBody(markdown) {
125
+ let inFence = false;
126
+ return markdown
127
+ .replace(/\s+$/, "")
128
+ .split("\n")
129
+ .map((line) => {
130
+ if (/^\s*(```|~~~)/.test(line)) {
131
+ inFence = !inFence;
132
+ return ` ${dim(line)}`;
133
+ }
134
+ if (inFence)
135
+ return ` ${line}`;
136
+ if (/^#{1,6}\s/.test(line)) {
137
+ return ` ${colors.bold}${line}${colors.reset}`;
138
+ }
139
+ if (/^\s*>/.test(line))
140
+ return ` ${dim(line)}`;
141
+ return ` ${line}`;
142
+ })
143
+ .join("\n");
144
+ }
145
+ /**
146
+ * One message: `Author 3m ago`, then the body indented under it. The author
147
+ * is bold because it is the line's anchor; the time is dim because it is
148
+ * context, not content.
149
+ */
150
+ export function renderChatMessage(msg, now) {
151
+ const author = capitalizeName(msg.author?.name ?? "Unknown");
152
+ const header = `${colors.bold}${author}${colors.reset} ${dim(relativeTime(msg._creationTime, now))}`;
153
+ return `${header}\n${renderChatBody(msg.body)}`;
154
+ }
155
+ /** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
156
+ export function renderRoomList(rooms) {
157
+ if (rooms.length === 0)
158
+ return dim("(no rooms)");
159
+ const width = Math.max(...rooms.map((r) => r.name.length));
160
+ return rooms
161
+ .map((room) => {
162
+ const marker = room.joined
163
+ ? `${colors.green}●${colors.reset}`
164
+ : dim("○");
165
+ const meta = room.joined
166
+ ? dim(room.type)
167
+ : dim(`${room.type} · not joined — sfora join ${roomSlug(room.name)}`);
168
+ return ` ${marker} ${room.name.padEnd(width)} ${meta}`;
169
+ })
170
+ .join("\n");
171
+ }
172
+ /**
173
+ * Poll until stopped, printing new messages in this room as they arrive.
174
+ *
175
+ * The same three rules `watchLoop` holds, held here for the same reasons: the
176
+ * cursor survives reconnects and only moves forward, drops back off (doubling
177
+ * to {@link MAX_BACKOFF_MS}) and recover fast, and nothing prints twice — the
178
+ * `seen` set is the single ledger, shared with the prompt's own sends.
179
+ */
180
+ export async function chatTailLoop(deps, options) {
181
+ const stopped = options.stopped ?? (() => false);
182
+ const firstBackoff = options.backoffMs ?? 1_000;
183
+ let cursor = options.since;
184
+ let backoff = firstBackoff;
185
+ while (!stopped()) {
186
+ let page;
187
+ try {
188
+ page = await deps.poll(cursor);
189
+ }
190
+ catch (error) {
191
+ if (stopped())
192
+ break;
193
+ const message = error instanceof Error ? error.message : String(error);
194
+ deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
195
+ await deps.sleep(backoff);
196
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
197
+ continue;
198
+ }
199
+ backoff = firstBackoff;
200
+ const rang = page.events.some((e) => e.type === "message" && e.roomId === options.roomId);
201
+ if (rang) {
202
+ try {
203
+ const recent = await deps.fetchRecent();
204
+ const fresh = recent
205
+ .filter((m) => !options.seen.has(m._id))
206
+ .sort((a, b) => a._creationTime - b._creationTime);
207
+ for (const msg of fresh) {
208
+ options.seen.add(msg._id);
209
+ deps.print(msg);
210
+ }
211
+ }
212
+ catch (error) {
213
+ const message = error instanceof Error ? error.message : String(error);
214
+ deps.warn(`could not fetch new messages — ${message}`);
215
+ }
216
+ }
217
+ // Never backwards: an empty page holds the cursor where it was.
218
+ cursor = Math.max(cursor, page.cursor);
219
+ }
220
+ return cursor;
221
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The CLI's argument grammar, as a pure function.
3
+ *
4
+ * Lifted out of `cli.ts` so it can be tested: importing `cli.ts` runs
5
+ * `main()`, which is exactly what a test must not do. Nothing here reads the
6
+ * process — argv in, a parsed shape out.
7
+ */
8
+ export interface CliArgs {
9
+ command?: string;
10
+ rest: string[];
11
+ org?: string;
12
+ cwd: string;
13
+ mcp: boolean;
14
+ help: boolean;
15
+ url?: string;
16
+ key?: string;
17
+ bot?: string;
18
+ as?: string;
19
+ web?: string;
20
+ project?: string;
21
+ column?: string;
22
+ draft: boolean;
23
+ json: boolean;
24
+ local: boolean;
25
+ cloud: boolean;
26
+ block?: string;
27
+ self: boolean;
28
+ wait?: number;
29
+ message?: string;
30
+ limit?: number;
31
+ client?: string;
32
+ }
33
+ export declare function parseArgs(argv: string[]): CliArgs;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The CLI's argument grammar, as a pure function.
3
+ *
4
+ * Lifted out of `cli.ts` so it can be tested: importing `cli.ts` runs
5
+ * `main()`, which is exactly what a test must not do. Nothing here reads the
6
+ * process — argv in, a parsed shape out.
7
+ */
8
+ export function parseArgs(argv) {
9
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const a = argv[i];
12
+ if (a === "--mcp")
13
+ args.mcp = true;
14
+ else if (a === "--help" || a === "-h")
15
+ args.help = true;
16
+ else if (a === "--org")
17
+ args.org = argv[++i];
18
+ else if (a.startsWith("--org="))
19
+ args.org = a.slice("--org=".length);
20
+ else if (a === "--cwd")
21
+ args.cwd = argv[++i] ?? "/";
22
+ else if (a.startsWith("--cwd="))
23
+ args.cwd = a.slice("--cwd=".length);
24
+ else if (a === "--url")
25
+ args.url = argv[++i];
26
+ else if (a.startsWith("--url="))
27
+ args.url = a.slice("--url=".length);
28
+ else if (a === "--key")
29
+ args.key = argv[++i];
30
+ else if (a.startsWith("--key="))
31
+ args.key = a.slice("--key=".length);
32
+ else if (a === "--bot" || a === "--agent")
33
+ args.bot = argv[++i];
34
+ else if (a.startsWith("--bot="))
35
+ args.bot = a.slice("--bot=".length);
36
+ else if (a.startsWith("--agent="))
37
+ args.bot = a.slice("--agent=".length);
38
+ else if (a === "--as")
39
+ args.as = argv[++i];
40
+ else if (a.startsWith("--as="))
41
+ args.as = a.slice("--as=".length);
42
+ else if (a === "--web")
43
+ args.web = argv[++i];
44
+ else if (a.startsWith("--web="))
45
+ args.web = a.slice("--web=".length);
46
+ else if (a === "--project")
47
+ args.project = argv[++i];
48
+ else if (a.startsWith("--project="))
49
+ args.project = a.slice("--project=".length);
50
+ else if (a === "--column")
51
+ args.column = argv[++i];
52
+ else if (a.startsWith("--column="))
53
+ args.column = a.slice("--column=".length);
54
+ else if (a === "--draft")
55
+ args.draft = true;
56
+ else if (a === "--json")
57
+ args.json = true;
58
+ else if (a === "--local")
59
+ args.local = true;
60
+ else if (a === "--cloud")
61
+ args.cloud = true;
62
+ else if (a === "--block")
63
+ args.block = argv[++i];
64
+ else if (a.startsWith("--block="))
65
+ args.block = a.slice("--block=".length);
66
+ else if (a === "--self")
67
+ args.self = true;
68
+ else if (a === "--wait")
69
+ args.wait = Number.parseInt(argv[++i] ?? "", 10);
70
+ else if (a.startsWith("--wait="))
71
+ args.wait = Number.parseInt(a.slice("--wait=".length), 10);
72
+ else if (a === "-m" || a === "--message")
73
+ args.message = argv[++i];
74
+ else if (a.startsWith("--message="))
75
+ args.message = a.slice("--message=".length);
76
+ else if (a === "-n" || a === "--limit")
77
+ args.limit = Number.parseInt(argv[++i] ?? "", 10);
78
+ else if (a.startsWith("--limit="))
79
+ args.limit = Number.parseInt(a.slice("--limit=".length), 10);
80
+ else if (a === "--client")
81
+ args.client = argv[++i];
82
+ else if (a.startsWith("--client="))
83
+ args.client = a.slice("--client=".length);
84
+ else if (!a.startsWith("-")) {
85
+ if (!args.command)
86
+ args.command = a;
87
+ else
88
+ args.rest.push(a);
89
+ }
90
+ }
91
+ return args;
92
+ }
package/dist/cli.js CHANGED
@@ -14,84 +14,13 @@ import { taskUploadFilename } from "./format/taskUploadFilename.js";
14
14
  import { createSforaShell, createLocalShell, } from "./index.js";
15
15
  import { openerCommand } from "./opener.js";
16
16
  import { blocksCommand, presenceNotice, putCommand, } from "./block-commands.js";
17
- import { colors, renderWriteEffect, urlLine, PRESENCE_NOTE } from "./render.js";
17
+ import { colors, ndjson, presenceRecords, renderPresence, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
18
18
  import { parseWatchTarget, watchLoop } from "./watch.js";
19
19
  import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, } from "./local/workspace.js";
20
20
  import { runMcpServer } from "./mcp-server.js";
21
21
  import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
22
- function parseArgs(argv) {
23
- const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false };
24
- for (let i = 0; i < argv.length; i++) {
25
- const a = argv[i];
26
- if (a === "--mcp")
27
- args.mcp = true;
28
- else if (a === "--help" || a === "-h")
29
- args.help = true;
30
- else if (a === "--org")
31
- args.org = argv[++i];
32
- else if (a.startsWith("--org="))
33
- args.org = a.slice("--org=".length);
34
- else if (a === "--cwd")
35
- args.cwd = argv[++i] ?? "/";
36
- else if (a.startsWith("--cwd="))
37
- args.cwd = a.slice("--cwd=".length);
38
- else if (a === "--url")
39
- args.url = argv[++i];
40
- else if (a.startsWith("--url="))
41
- args.url = a.slice("--url=".length);
42
- else if (a === "--key")
43
- args.key = argv[++i];
44
- else if (a.startsWith("--key="))
45
- args.key = a.slice("--key=".length);
46
- else if (a === "--bot" || a === "--agent")
47
- args.bot = argv[++i];
48
- else if (a.startsWith("--bot="))
49
- args.bot = a.slice("--bot=".length);
50
- else if (a.startsWith("--agent="))
51
- args.bot = a.slice("--agent=".length);
52
- else if (a === "--as")
53
- args.as = argv[++i];
54
- else if (a.startsWith("--as="))
55
- args.as = a.slice("--as=".length);
56
- else if (a === "--web")
57
- args.web = argv[++i];
58
- else if (a.startsWith("--web="))
59
- args.web = a.slice("--web=".length);
60
- else if (a === "--project")
61
- args.project = argv[++i];
62
- else if (a.startsWith("--project="))
63
- args.project = a.slice("--project=".length);
64
- else if (a === "--column")
65
- args.column = argv[++i];
66
- else if (a.startsWith("--column="))
67
- args.column = a.slice("--column=".length);
68
- else if (a === "--draft")
69
- args.draft = true;
70
- else if (a === "--json")
71
- args.json = true;
72
- else if (a === "--local")
73
- args.local = true;
74
- else if (a === "--cloud")
75
- args.cloud = true;
76
- else if (a === "--block")
77
- args.block = argv[++i];
78
- else if (a.startsWith("--block="))
79
- args.block = a.slice("--block=".length);
80
- else if (a === "--self")
81
- args.self = true;
82
- else if (a === "--wait")
83
- args.wait = Number.parseInt(argv[++i] ?? "", 10);
84
- else if (a.startsWith("--wait="))
85
- args.wait = Number.parseInt(a.slice("--wait=".length), 10);
86
- else if (!a.startsWith("-")) {
87
- if (!args.command)
88
- args.command = a;
89
- else
90
- args.rest.push(a);
91
- }
92
- }
93
- return args;
94
- }
22
+ import { parseArgs } from "./cli-args.js";
23
+ import { chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
95
24
  const HELP = `sfora — the CLI for your sfora workspace
96
25
 
97
26
  Get started (no account needed):
@@ -132,15 +61,29 @@ Browse & read:
132
61
  Add --json to any list command (projects/posts/tasks/ls/me) for
133
62
  machine-readable output and stable scripting.
134
63
 
64
+ Chat:
65
+ sfora rooms List rooms (● joined · ○ open to join)
66
+ sfora join <room> Join an open room
67
+ sfora chat <room> [-n <count>] Read the room, then type to talk —
68
+ new messages stream in live
69
+ sfora chat <room> -m "text" Send one message and exit (for scripts
70
+ and agents)
71
+
72
+ Chat shows others what's on the line: the CLI reports itself in presence,
73
+ and a coding agent is named automatically (Claude Code sets its own
74
+ environment). Add --client <name> to say it yourself.
75
+
135
76
  Write, and watch others write:
136
77
  sfora blocks <path> List a document's addressable blocks
137
78
  sfora put <path> <file.md> Write a file (add --block <id> for one block)
138
79
  sfora put <path> --block <id> - …or pipe the block's markdown on stdin
139
80
  sfora watch <path-or-project> Stream write pings (add --json for NDJSON)
81
+ sfora where [name] Who's in which document right now
140
82
 
141
83
  Every write prints what it did — "changed" (with how many block ids
142
84
  survived) or "no change". Watching and writing make you visible in the
143
- document; watch --self also shows your own writes.
85
+ document; watch --self also shows your own writes. "where" is a read only —
86
+ asking never puts you in a document.
144
87
 
145
88
  Agents & config:
146
89
  sfora --mcp [--org <slug>] Run as an MCP server (for agents)
@@ -408,6 +351,7 @@ const VERBS = new Set([
408
351
  "blocks",
409
352
  "put",
410
353
  "watch",
354
+ "where",
411
355
  "post",
412
356
  "task",
413
357
  "doc",
@@ -419,6 +363,9 @@ const VERBS = new Set([
419
363
  "whoami",
420
364
  "comment",
421
365
  "react",
366
+ "rooms",
367
+ "join",
368
+ "chat",
422
369
  ]);
423
370
  async function runVerb(args, fs, client) {
424
371
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
@@ -528,6 +475,59 @@ async function runVerb(args, fs, client) {
528
475
  }
529
476
  return runWatch(args, client, args.rest[0]);
530
477
  }
478
+ // `where` — the reverse of presence. Card #345.
479
+ //
480
+ // Nothing is declared by running it: the server answers a query and the CLI
481
+ // prints it. That matters enough to be a property of the verb rather than a
482
+ // note about it — `sfora where` is the command an agent runs to find out
483
+ // where a human is working, and a version of it that joined the roster would
484
+ // make an agent appear in a document it had only asked about.
485
+ //
486
+ // The name is the whole argument list. `--as` composes for free (it is a
487
+ // header on the client, so `sfora where --as bot` asks as the bot and gets
488
+ // the bot's visibility), and no `--project` filter exists because the answer
489
+ // is already scoped to what this key can open.
490
+ if (args.command === "where") {
491
+ const view = await client.listPresence(args.rest[0]);
492
+ if (args.json) {
493
+ // NDJSON, matching `watch --json`: one record per line, no wrapper. A
494
+ // pretty-printed object would break `sfora where --json | jq -r .url`
495
+ // for the same reason it would there.
496
+ for (const record of presenceRecords(view)) {
497
+ process.stdout.write(`${ndjson(record)}\n`);
498
+ }
499
+ return;
500
+ }
501
+ console.log(renderPresence(view));
502
+ return;
503
+ }
504
+ // Chat — the same rooms the app shows, from the terminal. Humans and agents
505
+ // are the same member model, so this is how either sits in the conversation.
506
+ if (args.command === "rooms") {
507
+ const rooms = await client.listRooms(true);
508
+ if (args.json)
509
+ return emitJson(rooms);
510
+ console.log(renderRoomList(rooms));
511
+ return;
512
+ }
513
+ if (args.command === "join") {
514
+ if (!args.rest[0])
515
+ throw new Error("usage: sfora join <room>");
516
+ const room = await resolveRoomOrThrow(client, args.rest[0]);
517
+ const result = await client.joinRoom(room._id);
518
+ if (args.json)
519
+ return emitJson({ ...result, roomId: room._id, name: room.name });
520
+ ok(result.already
521
+ ? `Already in #${roomSlug(room.name)}`
522
+ : `Joined #${roomSlug(room.name)}`);
523
+ return;
524
+ }
525
+ if (args.command === "chat") {
526
+ if (!args.rest[0]) {
527
+ throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"]');
528
+ }
529
+ return runChat(args, client);
530
+ }
531
531
  if (args.command === "me" || args.command === "whoami") {
532
532
  const text = (await client.readMe()).trim();
533
533
  if (args.json) {
@@ -757,6 +757,179 @@ async function runWatch(args, client, target) {
757
757
  process.off("SIGTERM", onSignal);
758
758
  }
759
759
  }
760
+ /**
761
+ * The room a reference names, or a thrown explanation.
762
+ *
763
+ * Resolution reads `?all=1` so an unjoined room can still be found — `sfora
764
+ * join dogfood` has to work before the caller is in it. Ambiguity throws with
765
+ * the candidates rather than guessing: sending into the wrong room is the
766
+ * failure worth a retype.
767
+ */
768
+ async function resolveRoomOrThrow(client, ref) {
769
+ const rooms = await client.listRooms(true);
770
+ const match = resolveRoomRef(rooms, ref);
771
+ if (match.kind === "match")
772
+ return match.room;
773
+ if (match.kind === "ambiguous") {
774
+ throw new Error(`'${ref}' matches several rooms — say which:\n` +
775
+ match.candidates.map((r) => ` ${roomSlug(r.name)}`).join("\n"));
776
+ }
777
+ throw new Error(`no room matches '${ref}' — run \`sfora rooms\` to see them`);
778
+ }
779
+ /**
780
+ * `sfora chat <room>` — the room in the terminal, wired to the real process.
781
+ *
782
+ * Everything decision-shaped (name resolution, formatting, the tail loop)
783
+ * lives in `chat.ts` where it is tested; this owns readline, ^C, and the two
784
+ * modes: `-m` sends one message and exits (what scripts and agents use), bare
785
+ * `chat` prints history and opens a prompt with a live tail above it.
786
+ */
787
+ async function runChat(args, client) {
788
+ const room = await resolveRoomOrThrow(client, args.rest[0]);
789
+ const tag = `#${roomSlug(room.name)}`;
790
+ const isTty = Boolean(process.stdin.isTTY);
791
+ if (!room.joined) {
792
+ // Offered inline on a terminal; stated as the fix everywhere else — a
793
+ // script cannot answer a question, so it gets the command instead.
794
+ if (args.message !== undefined || !isTty) {
795
+ throw new Error(`you have not joined ${tag} — run \`sfora join ${roomSlug(room.name)}\` first`);
796
+ }
797
+ const ask = readline.createInterface({
798
+ input: process.stdin,
799
+ output: process.stdout,
800
+ });
801
+ const answer = await new Promise((res) => ask.question(`You have not joined ${tag} — join? (y/n) `, res));
802
+ ask.close();
803
+ if (!/^y(es)?$/i.test(answer.trim()))
804
+ return;
805
+ await client.joinRoom(room._id);
806
+ console.log(`${colors.green}✓${colors.reset} Joined ${tag}`);
807
+ }
808
+ // Presence is decoration: a beat that fails changes nothing about the
809
+ // conversation, so every beat here is best-effort and silent. The label
810
+ // says WHAT is on the line — "cli", or "claude-code" when a coding agent
811
+ // is driving — so the app can show the terminal next to the online dot.
812
+ const clientLabel = detectClient(process.env, args.client);
813
+ const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
814
+ // One-shot send.
815
+ if (args.message !== undefined) {
816
+ const text = args.message.trim();
817
+ if (!text)
818
+ throw new Error('usage: sfora chat <room> -m "text"');
819
+ // One beat alongside the send — a script that speaks was here, briefly.
820
+ void beat();
821
+ const { messageId } = await client.sendRoomMessage(room._id, text);
822
+ if (args.json) {
823
+ console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
824
+ return;
825
+ }
826
+ console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
827
+ return;
828
+ }
829
+ // History, oldest first — the page arrives newest-first.
830
+ const limit = Number.isFinite(args.limit) && args.limit > 0
831
+ ? Math.min(args.limit, 100)
832
+ : 30;
833
+ // Seed the seen set from as many messages as the tail's refetch pulls (20):
834
+ // with -n below that, the doorbell would otherwise "discover" older history
835
+ // and flood it as if it had just arrived. Print only the asked-for n.
836
+ const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
837
+ const seeded = history.page.slice().reverse();
838
+ const seen = new Set(seeded.map((m) => m._id));
839
+ const messages = seeded.slice(-limit);
840
+ if (messages.length === 0) {
841
+ console.log(`${colors.dim}(no messages yet)${colors.reset}`);
842
+ }
843
+ for (const msg of messages)
844
+ console.log(renderChatMessage(msg, Date.now()));
845
+ process.stderr.write(`${colors.dim}${tag} — type to send · /quit (or ^C) to leave${colors.reset}\n`);
846
+ // Sitting in the room is being present in it: beat now, then every ~45s
847
+ // (presence expires server-side, so the cadence just has to outrun the
848
+ // timeout). `unref` so the timer never keeps the process alive on its own.
849
+ void beat();
850
+ const pulse = setInterval(() => void beat(), 45_000);
851
+ pulse.unref?.();
852
+ // The prompt and the tail share one screen: an arriving message clears the
853
+ // prompt line, prints, and readline redraws the prompt with whatever was
854
+ // being typed. `readline.clearLine` over anything fancier — robust beats
855
+ // pretty at 80 columns.
856
+ const rl = readline.createInterface({
857
+ input: process.stdin,
858
+ output: process.stdout,
859
+ terminal: isTty,
860
+ });
861
+ rl.setPrompt(`${colors.cyan}${tag}${colors.reset} `);
862
+ const printAbove = (text) => {
863
+ if (isTty) {
864
+ readline.clearLine(process.stdout, 0);
865
+ readline.cursorTo(process.stdout, 0);
866
+ }
867
+ process.stdout.write(`${text}\n`);
868
+ if (isTty)
869
+ rl.prompt(true);
870
+ };
871
+ // ^C has to reach the socket, same as `watch`: the long-poll hangs for tens
872
+ // of seconds, and a flag alone would leave a terminal that says it stopped
873
+ // and hasn't. Closing readline ends the line iterator; aborting the request
874
+ // ends the poll; the loop sees `stopped()` and returns.
875
+ let stop = false;
876
+ const inFlight = new AbortController();
877
+ rl.on("SIGINT", () => rl.close());
878
+ rl.on("close", () => {
879
+ stop = true;
880
+ inFlight.abort();
881
+ });
882
+ const wait = Number.isFinite(args.wait) ? args.wait : undefined;
883
+ const tail = chatTailLoop({
884
+ poll: (since) => client.pollEvents({ since, wait, signal: inFlight.signal }),
885
+ fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
886
+ print: (msg) => printAbove(renderChatMessage(msg, Date.now())),
887
+ warn: (text) => printAbove(`${colors.dim}${text}${colors.reset}`),
888
+ sleep,
889
+ }, {
890
+ roomId: room._id,
891
+ seen,
892
+ // Cursor on SERVER time: the newest history message's _creationTime.
893
+ // A client clock running fast would otherwise open a blind window at
894
+ // startup; any overlap re-delivery is absorbed by the seen set.
895
+ since: history.page[0]?._creationTime ?? Date.now(),
896
+ stopped: () => stop,
897
+ });
898
+ if (isTty)
899
+ rl.prompt();
900
+ for await (const input of rl) {
901
+ const line = input.trim();
902
+ if (!line) {
903
+ if (isTty)
904
+ rl.prompt();
905
+ continue;
906
+ }
907
+ if (line === "/quit" || line === "/exit" || line === "/q")
908
+ break;
909
+ try {
910
+ // The send's own echo is the typed line still on screen; recording the
911
+ // id keeps the tail's refetch from printing it a second time.
912
+ const { messageId } = await client.sendRoomMessage(room._id, line);
913
+ seen.add(messageId);
914
+ }
915
+ catch (e) {
916
+ const msg = e instanceof Error ? e.message : String(e);
917
+ process.stderr.write(`${colors.red}error:${colors.reset} ${msg}\n`);
918
+ }
919
+ if (isTty)
920
+ rl.prompt();
921
+ }
922
+ stop = true;
923
+ inFlight.abort();
924
+ rl.close();
925
+ clearInterval(pulse);
926
+ // A parting beat, best-effort — awaited so quitting doesn't race process
927
+ // exit, but never allowed to hold the door.
928
+ await beat();
929
+ await tail.catch(() => { });
930
+ if (isTty)
931
+ console.log("");
932
+ }
760
933
  /**
761
934
  * A shared command's result, on the real streams.
762
935
  *
package/dist/render.d.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  * SERVER SENDS. Nothing here computes a URL, a route, or a block id — each
11
11
  * comes off a response and is formatted.
12
12
  */
13
- import type { BlockConflict, BlockView, BlocksView, WriteEffect } from "./api-client.js";
13
+ import type { BlockConflict, BlockView, BlocksView, PresenceView, WriteEffect } from "./api-client.js";
14
14
  export declare const colors: {
15
15
  reset: string;
16
16
  bold: string;
@@ -89,6 +89,36 @@ export declare function renderWriteEffect(effect: WriteEffect | null): string |
89
89
  * noise and the second one teaches nothing.
90
90
  */
91
91
  export declare const PRESENCE_NOTE = "you are visible as editing this document";
92
+ /**
93
+ * `sfora where` — one sentence per person, and a link they can be met at.
94
+ *
95
+ * A SENTENCE, not a table. The answer to "where is Thijs" is read once and
96
+ * acted on immediately ("open that document"), so it is written the way it
97
+ * would be said: *Thijs is editing test-document.md — <url>*. A columnar
98
+ * listing would be denser and would make the reader assemble the sentence
99
+ * themselves.
100
+ *
101
+ * The block id rides in parentheses only when somebody claimed one. Humans
102
+ * are present at document level today (the editor has no cheap source-offset
103
+ * mapping), so an always-present column would be empty on most rows and read
104
+ * as a fault rather than as an absence of claim.
105
+ *
106
+ * Grouped runs, no headers: two people in one document print as two adjacent
107
+ * lines naming the same file. The repetition is the grouping, and it costs
108
+ * nothing to scan — where a header would cost a line per document and break
109
+ * `sfora where | grep` into a two-line lookup.
110
+ */
111
+ export declare function renderPresence(view: PresenceView): string;
112
+ /**
113
+ * `sfora where --json` — one flat record per person-in-a-document, per line.
114
+ *
115
+ * FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
116
+ * record, and the record a consumer of this wants is "who is where": grouping
117
+ * would make a line's shape depend on how many people happened to be in one
118
+ * file, and `jq -r .url` would stop working. The document's fields are copied
119
+ * onto each line rather than referenced, so no line needs another to be read.
120
+ */
121
+ export declare function presenceRecords(view: PresenceView): unknown[];
92
122
  /** One `doc.write` / `doc.delete` ping from `/v1/events`, as one line. */
93
123
  export interface DocPing {
94
124
  type: "doc.write" | "doc.delete";
package/dist/render.js CHANGED
@@ -163,6 +163,78 @@ export function renderWriteEffect(effect) {
163
163
  * noise and the second one teaches nothing.
164
164
  */
165
165
  export const PRESENCE_NOTE = "you are visible as editing this document";
166
+ /**
167
+ * `sfora where` — one sentence per person, and a link they can be met at.
168
+ *
169
+ * A SENTENCE, not a table. The answer to "where is Thijs" is read once and
170
+ * acted on immediately ("open that document"), so it is written the way it
171
+ * would be said: *Thijs is editing test-document.md — <url>*. A columnar
172
+ * listing would be denser and would make the reader assemble the sentence
173
+ * themselves.
174
+ *
175
+ * The block id rides in parentheses only when somebody claimed one. Humans
176
+ * are present at document level today (the editor has no cheap source-offset
177
+ * mapping), so an always-present column would be empty on most rows and read
178
+ * as a fault rather than as an absence of claim.
179
+ *
180
+ * Grouped runs, no headers: two people in one document print as two adjacent
181
+ * lines naming the same file. The repetition is the grouping, and it costs
182
+ * nothing to scan — where a header would cost a line per document and break
183
+ * `sfora where | grep` into a two-line lookup.
184
+ */
185
+ export function renderPresence(view) {
186
+ const lines = [];
187
+ const totalHere = view.documents.reduce((n, d) => n + d.here.length, 0);
188
+ if (totalHere === 0) {
189
+ return dim(view.member
190
+ ? `${view.member.name} isn't in a document right now`
191
+ : "nobody is in a document right now");
192
+ }
193
+ for (const doc of view.documents) {
194
+ for (const who of doc.here) {
195
+ lines.push(presenceLine(doc, who));
196
+ }
197
+ }
198
+ return lines.join("\n");
199
+ }
200
+ function presenceLine(doc, who) {
201
+ const where = who.block
202
+ ? `${doc.filename} ${dim(`(block ${who.block})`)}`
203
+ : doc.filename;
204
+ return `${who.name} is ${who.kind} ${where} ${dim("—")} ${urlLine(doc.url)}`;
205
+ }
206
+ /**
207
+ * `sfora where --json` — one flat record per person-in-a-document, per line.
208
+ *
209
+ * FLAT, where the wire shape is grouped. NDJSON's contract is that a line is a
210
+ * record, and the record a consumer of this wants is "who is where": grouping
211
+ * would make a line's shape depend on how many people happened to be in one
212
+ * file, and `jq -r .url` would stop working. The document's fields are copied
213
+ * onto each line rather than referenced, so no line needs another to be read.
214
+ */
215
+ export function presenceRecords(view) {
216
+ const records = [];
217
+ for (const doc of view.documents) {
218
+ for (const who of doc.here) {
219
+ records.push({
220
+ memberId: who.memberId,
221
+ name: who.name,
222
+ type: who.type,
223
+ kind: who.kind,
224
+ block: who.block,
225
+ lastSeenAt: who.lastSeenAt,
226
+ ttlSeconds: view.ttlSeconds,
227
+ docId: doc.docId,
228
+ title: doc.title,
229
+ filename: doc.filename,
230
+ path: doc.path,
231
+ project: doc.project.slug,
232
+ url: doc.url,
233
+ });
234
+ }
235
+ }
236
+ return records;
237
+ }
166
238
  /** `HH:MM:SS` in the reader's own timezone — a ping is read as it lands. */
167
239
  export function pingTime(ts) {
168
240
  return new Date(ts).toTimeString().slice(0, 8);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
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": [