sfora-cli 0.12.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.
@@ -576,6 +576,18 @@ export declare class SforaApiClient {
576
576
  sendRoomMessage(roomId: string, body: string): Promise<{
577
577
  messageId: string;
578
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>;
579
591
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
580
592
  readInbox(): Promise<string>;
581
593
  /** `GET /v1/fs/me/api-key` — text identity (no key material). */
@@ -637,6 +637,17 @@ export class SforaApiClient {
637
637
  const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/messages`, JSON.stringify({ body }), undefined, "application/json");
638
638
  return this.#jsonFrom(res);
639
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
+ }
640
651
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
641
652
  async readInbox() {
642
653
  return this.#text("/v1/fs/inbox/mentions.md");
package/dist/chat.d.ts CHANGED
@@ -17,6 +17,22 @@
17
17
  * print twice.
18
18
  */
19
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;
20
36
  /** How `sfora rooms` and `sfora join` spell a room name as an argument. */
21
37
  export declare function roomSlug(name: string): string;
22
38
  export type RoomMatch = {
package/dist/chat.js CHANGED
@@ -19,6 +19,38 @@
19
19
  import { colors } from "./render.js";
20
20
  import { MAX_BACKOFF_MS } from "./watch.js";
21
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
+ }
22
54
  // ─── Room resolution ─────────────────────────────────────────────────
23
55
  /** How `sfora rooms` and `sfora join` spell a room name as an argument. */
24
56
  export function roomSlug(name) {
@@ -28,5 +28,6 @@ export interface CliArgs {
28
28
  wait?: number;
29
29
  message?: string;
30
30
  limit?: number;
31
+ client?: string;
31
32
  }
32
33
  export declare function parseArgs(argv: string[]): CliArgs;
package/dist/cli-args.js CHANGED
@@ -77,6 +77,10 @@ export function parseArgs(argv) {
77
77
  args.limit = Number.parseInt(argv[++i] ?? "", 10);
78
78
  else if (a.startsWith("--limit="))
79
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);
80
84
  else if (!a.startsWith("-")) {
81
85
  if (!args.command)
82
86
  args.command = a;
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, }
20
20
  import { runMcpServer } from "./mcp-server.js";
21
21
  import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
22
22
  import { parseArgs } from "./cli-args.js";
23
- import { chatTailLoop, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
23
+ import { chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
24
24
  const HELP = `sfora — the CLI for your sfora workspace
25
25
 
26
26
  Get started (no account needed):
@@ -69,6 +69,10 @@ Chat:
69
69
  sfora chat <room> -m "text" Send one message and exit (for scripts
70
70
  and agents)
71
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
+
72
76
  Write, and watch others write:
73
77
  sfora blocks <path> List a document's addressable blocks
74
78
  sfora put <path> <file.md> Write a file (add --block <id> for one block)
@@ -801,11 +805,19 @@ async function runChat(args, client) {
801
805
  await client.joinRoom(room._id);
802
806
  console.log(`${colors.green}✓${colors.reset} Joined ${tag}`);
803
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(() => { });
804
814
  // One-shot send.
805
815
  if (args.message !== undefined) {
806
816
  const text = args.message.trim();
807
817
  if (!text)
808
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();
809
821
  const { messageId } = await client.sendRoomMessage(room._id, text);
810
822
  if (args.json) {
811
823
  console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
@@ -831,6 +843,12 @@ async function runChat(args, client) {
831
843
  for (const msg of messages)
832
844
  console.log(renderChatMessage(msg, Date.now()));
833
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?.();
834
852
  // The prompt and the tail share one screen: an arriving message clears the
835
853
  // prompt line, prints, and readline redraws the prompt with whatever was
836
854
  // being typed. `readline.clearLine` over anything fancier — robust beats
@@ -904,6 +922,10 @@ async function runChat(args, client) {
904
922
  stop = true;
905
923
  inFlight.abort();
906
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();
907
929
  await tail.catch(() => { });
908
930
  if (isTty)
909
931
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.12.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": [