sfora-cli 0.12.1 → 0.13.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.
@@ -573,7 +573,9 @@ export declare class SforaApiClient {
573
573
  /** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
574
574
  listRoomMessages(roomId: string, limit?: number): Promise<MessagesPage>;
575
575
  /** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
576
- sendRoomMessage(roomId: string, body: string): Promise<{
576
+ sendRoomMessage(roomId: string, body: string,
577
+ /** Terminal client slug — stamped on the message ("sent via Claude Code"). */
578
+ client?: string): Promise<{
577
579
  messageId: string;
578
580
  }>;
579
581
  /**
@@ -588,6 +590,25 @@ export declare class SforaApiClient {
588
590
  roomId?: string;
589
591
  client?: string;
590
592
  }): Promise<void>;
593
+ /**
594
+ * `POST /api/asks` — post a coordination ask, or (with `options`) a
595
+ * QUESTION: 2–4 short answers for a human to pick from. `target` is a
596
+ * member NAME (or id), resolved server-side; an ambiguous name comes back
597
+ * as a 400 listing the candidates. Only humans can answer a question —
598
+ * enforced in the mutation, not here.
599
+ */
600
+ createAsk(params: {
601
+ text: string;
602
+ options?: string[];
603
+ /** Member name or id the question is for — a human. */
604
+ target?: string;
605
+ /** Project slug the ask belongs to. */
606
+ project?: string;
607
+ /** Room to announce the ask into. */
608
+ roomId?: string;
609
+ }): Promise<{
610
+ askId: string;
611
+ }>;
591
612
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
592
613
  readInbox(): Promise<string>;
593
614
  /** `GET /v1/fs/me/api-key` — text identity (no key material). */
@@ -631,10 +631,12 @@ export class SforaApiClient {
631
631
  return this.#json(`/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`);
632
632
  }
633
633
  /** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
634
- async sendRoomMessage(roomId, body) {
634
+ async sendRoomMessage(roomId, body,
635
+ /** Terminal client slug — stamped on the message ("sent via Claude Code"). */
636
+ client) {
635
637
  // Through #request like every other call, so network failures surface as
636
638
  // 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");
639
+ const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/messages`, JSON.stringify({ body, client }), undefined, "application/json");
638
640
  return this.#jsonFrom(res);
639
641
  }
640
642
  /**
@@ -648,6 +650,26 @@ export class SforaApiClient {
648
650
  async heartbeat(params = {}) {
649
651
  await this.#request("POST", "/api/presence", JSON.stringify(params), undefined, "application/json");
650
652
  }
653
+ /**
654
+ * `POST /api/asks` — post a coordination ask, or (with `options`) a
655
+ * QUESTION: 2–4 short answers for a human to pick from. `target` is a
656
+ * member NAME (or id), resolved server-side; an ambiguous name comes back
657
+ * as a 400 listing the candidates. Only humans can answer a question —
658
+ * enforced in the mutation, not here.
659
+ */
660
+ async createAsk(params) {
661
+ const body = { text: params.text };
662
+ if (params.options)
663
+ body.options = params.options;
664
+ if (params.target)
665
+ body.target = params.target;
666
+ if (params.project)
667
+ body.project = params.project;
668
+ if (params.roomId)
669
+ body.roomId = params.roomId;
670
+ const res = await this.#request("POST", "/api/asks", JSON.stringify(body), undefined, "application/json");
671
+ return this.#jsonFrom(res);
672
+ }
651
673
  /** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
652
674
  async readInbox() {
653
675
  return this.#text("/v1/fs/inbox/mentions.md");
package/dist/ask.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * `sfora ask` — an agent asks a human, from the terminal.
3
+ *
4
+ * An ask with options is a QUESTION: 2–4 short answers for a human to pick
5
+ * from, optionally aimed at one person with `--for`. Only humans can answer —
6
+ * the server enforces that in the mutation, this module just says it in the
7
+ * error copy. Everything decision-shaped lives here as pure functions
8
+ * (validation, the wait loop, the ambiguity reformat) so it can be tested with
9
+ * canned data; `cli.ts` owns the process — signals, the real streams.
10
+ *
11
+ * The wait loop rides `/v1/events`, the same long-poll `sfora watch` and
12
+ * `sfora chat` use: the feed carries an `ask` event for every state change of
13
+ * an ask the caller created, and a resolved one carries `chosenOption` (the
14
+ * picked answer) and `resolution`. So waiting is a wake, not a poll schedule.
15
+ */
16
+ import type { AgentEventsPage } from "./api-client.js";
17
+ export declare const ASK_OPTIONS_MIN = 2;
18
+ export declare const ASK_OPTIONS_MAX = 4;
19
+ export declare const ASK_OPTION_MAX_LENGTH = 80;
20
+ /**
21
+ * The options as they will be sent, or a thrown explanation.
22
+ *
23
+ * Mirrors the server's write-door rule (2–4 entries, trimmed nonempty, ≤80
24
+ * chars) so a typo fails at the prompt with a usable message instead of as a
25
+ * 400 after a round trip. The server still validates — this is a courtesy,
26
+ * not the gate.
27
+ */
28
+ export declare function validateAskOptions(raw: string[]): string[];
29
+ /**
30
+ * A server refusal that lists candidates, reshaped for a terminal.
31
+ *
32
+ * The `/api/asks` door answers an ambiguous `--for` with one line:
33
+ * `Ambiguous target 'sam' (candidates: Sam One <id>, Sam Two <id>)`. Reshaped
34
+ * to the same say-which layout `sfora join` uses for rooms — one candidate per
35
+ * line, so the retype is a copy. Any message without the candidates suffix is
36
+ * returned unchanged.
37
+ */
38
+ export declare function reshapeCandidatesError(message: string): string;
39
+ /** Everything the wait loop needs from the outside world. */
40
+ export interface AskWaitDeps {
41
+ /** One `/v1/events` long-poll. Rejects on a drop — the loop backs off. */
42
+ poll(since: number): Promise<AgentEventsPage>;
43
+ /** A warning, on stderr — never mixed into stdout. */
44
+ warn(text: string): void;
45
+ sleep(ms: number): Promise<void>;
46
+ /** The clock, injectable for tests. */
47
+ now?(): number;
48
+ }
49
+ export interface AskWaitOptions {
50
+ askId: string;
51
+ /** Events cursor to start from — BEFORE the ask was created, with slack. */
52
+ since: number;
53
+ /** Absolute deadline (ms epoch); absent means wait forever. */
54
+ deadlineMs?: number;
55
+ stopped?: () => boolean;
56
+ backoffMs?: number;
57
+ }
58
+ export type AskWaitResult = {
59
+ outcome: "answered";
60
+ chosenOption?: string;
61
+ resolution?: string;
62
+ } | {
63
+ outcome: "timeout";
64
+ } | {
65
+ outcome: "stopped";
66
+ };
67
+ /**
68
+ * Poll until the ask resolves, the deadline passes, or the caller stops.
69
+ *
70
+ * The same rules the other loops hold: the cursor survives reconnects and
71
+ * only moves forward, drops back off (doubling to {@link MAX_BACKOFF_MS}) and
72
+ * recover fast. Only a `resolved` event for THIS ask ends the wait — claims
73
+ * and other asks' traffic pass through silently.
74
+ */
75
+ export declare function askWaitLoop(deps: AskWaitDeps, options: AskWaitOptions): Promise<AskWaitResult>;
package/dist/ask.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `sfora ask` — an agent asks a human, from the terminal.
3
+ *
4
+ * An ask with options is a QUESTION: 2–4 short answers for a human to pick
5
+ * from, optionally aimed at one person with `--for`. Only humans can answer —
6
+ * the server enforces that in the mutation, this module just says it in the
7
+ * error copy. Everything decision-shaped lives here as pure functions
8
+ * (validation, the wait loop, the ambiguity reformat) so it can be tested with
9
+ * canned data; `cli.ts` owns the process — signals, the real streams.
10
+ *
11
+ * The wait loop rides `/v1/events`, the same long-poll `sfora watch` and
12
+ * `sfora chat` use: the feed carries an `ask` event for every state change of
13
+ * an ask the caller created, and a resolved one carries `chosenOption` (the
14
+ * picked answer) and `resolution`. So waiting is a wake, not a poll schedule.
15
+ */
16
+ import { MAX_BACKOFF_MS } from "./watch.js";
17
+ // ─── Option validation (the client-side half of the write gate) ──────
18
+ export const ASK_OPTIONS_MIN = 2;
19
+ export const ASK_OPTIONS_MAX = 4;
20
+ export const ASK_OPTION_MAX_LENGTH = 80;
21
+ /**
22
+ * The options as they will be sent, or a thrown explanation.
23
+ *
24
+ * Mirrors the server's write-door rule (2–4 entries, trimmed nonempty, ≤80
25
+ * chars) so a typo fails at the prompt with a usable message instead of as a
26
+ * 400 after a round trip. The server still validates — this is a courtesy,
27
+ * not the gate.
28
+ */
29
+ export function validateAskOptions(raw) {
30
+ const options = raw.map((o) => o.trim());
31
+ const empty = options.findIndex((o) => !o);
32
+ if (empty !== -1) {
33
+ throw new Error(`option ${empty + 1} is empty — every option needs text`);
34
+ }
35
+ if (options.length < ASK_OPTIONS_MIN || options.length > ASK_OPTIONS_MAX) {
36
+ throw new Error(`a question carries ${ASK_OPTIONS_MIN}–${ASK_OPTIONS_MAX} options — you gave ${options.length}. ` +
37
+ 'Repeat --option for each answer: --option "Basic" --option "Business"');
38
+ }
39
+ const long = options.find((o) => o.length > ASK_OPTION_MAX_LENGTH);
40
+ if (long) {
41
+ throw new Error(`an option is a short answer, not a paragraph — keep each under ${ASK_OPTION_MAX_LENGTH} characters ` +
42
+ `("${long.slice(0, 40)}…" is ${long.length})`);
43
+ }
44
+ const seen = new Set();
45
+ for (let i = 0; i < options.length; i++) {
46
+ if (seen.has(options[i])) {
47
+ throw new Error(`option ${i + 1} repeats "${options[i]}" — every option must be distinct`);
48
+ }
49
+ seen.add(options[i]);
50
+ }
51
+ return options;
52
+ }
53
+ /**
54
+ * A server refusal that lists candidates, reshaped for a terminal.
55
+ *
56
+ * The `/api/asks` door answers an ambiguous `--for` with one line:
57
+ * `Ambiguous target 'sam' (candidates: Sam One <id>, Sam Two <id>)`. Reshaped
58
+ * to the same say-which layout `sfora join` uses for rooms — one candidate per
59
+ * line, so the retype is a copy. Any message without the candidates suffix is
60
+ * returned unchanged.
61
+ */
62
+ export function reshapeCandidatesError(message) {
63
+ const m = /^(.*?)\s*\(candidates:\s*(.+)\)\s*$/.exec(message);
64
+ if (!m)
65
+ return message;
66
+ return (`${m[1].trim()} — say which:\n` +
67
+ m[2]
68
+ .split(", ")
69
+ .map((c) => ` ${c}`)
70
+ .join("\n"));
71
+ }
72
+ /**
73
+ * Poll until the ask resolves, the deadline passes, or the caller stops.
74
+ *
75
+ * The same rules the other loops hold: the cursor survives reconnects and
76
+ * only moves forward, drops back off (doubling to {@link MAX_BACKOFF_MS}) and
77
+ * recover fast. Only a `resolved` event for THIS ask ends the wait — claims
78
+ * and other asks' traffic pass through silently.
79
+ */
80
+ export async function askWaitLoop(deps, options) {
81
+ const stopped = options.stopped ?? (() => false);
82
+ const now = deps.now ?? Date.now;
83
+ const firstBackoff = options.backoffMs ?? 1_000;
84
+ let cursor = options.since;
85
+ let backoff = firstBackoff;
86
+ while (!stopped()) {
87
+ if (options.deadlineMs !== undefined && now() >= options.deadlineMs) {
88
+ return { outcome: "timeout" };
89
+ }
90
+ let page;
91
+ try {
92
+ page = await deps.poll(cursor);
93
+ }
94
+ catch (error) {
95
+ if (stopped())
96
+ break;
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
99
+ await deps.sleep(backoff);
100
+ backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
101
+ continue;
102
+ }
103
+ backoff = firstBackoff;
104
+ for (const event of page.events) {
105
+ if (event.type !== "ask" || event.askId !== options.askId)
106
+ continue;
107
+ if (event.askState !== "resolved")
108
+ continue;
109
+ return {
110
+ outcome: "answered",
111
+ chosenOption: typeof event.chosenOption === "string" ? event.chosenOption : undefined,
112
+ resolution: typeof event.resolution === "string" ? event.resolution : undefined,
113
+ };
114
+ }
115
+ // Never backwards: an empty page holds the cursor where it was.
116
+ cursor = Math.max(cursor, page.cursor);
117
+ }
118
+ return { outcome: "stopped" };
119
+ }
package/dist/chat.d.ts CHANGED
@@ -23,14 +23,25 @@ import type { AgentEventsPage, ChatMessage, Room } from "./api-client.js";
23
23
  * the caller falls through to its next guess.
24
24
  */
25
25
  export declare function sanitizeClientSlug(raw: string | undefined): string | undefined;
26
+ /**
27
+ * The client slugs sfora knows by name — the shared vocabulary with the web
28
+ * app's client marks. Unknown slugs stay valid everywhere; they just render
29
+ * the generic terminal glyph in the app.
30
+ */
31
+ export declare const KNOWN_CLIENTS: readonly ["claude-code", "codex", "cursor", "gemini", "windsurf", "aider", "opencode", "cli"];
26
32
  /**
27
33
  * What kind of client is on the line, for the presence label.
28
34
  *
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.
35
+ * Order: an explicit `--client` flag wins; then the per-client env markers in
36
+ * {@link CLIENT_MARKERS}; then `AI_AGENT` (the part before the first
37
+ * underscore — `claude-code_2-1-233_agent` names "claude-code"); then
38
+ * `CLAUDE_CODE_ENTRYPOINT` or `CLAUDECODE` being set at all means Claude Code
39
+ * is driving (kept below `AI_AGENT`, its long-standing rung); then
40
+ * `CURSOR_TRACE_ID` — set by Cursor's integrated TERMINAL and inherited by
41
+ * everything launched from it, so it sits at the bottom: Claude Code run
42
+ * inside Cursor must read as claude-code, not as the window around it. Else
43
+ * this is a plain terminal — "cli". Env is passed in, not read, so every rung
44
+ * is testable.
34
45
  */
35
46
  export declare function detectClient(env: Record<string, string | undefined>, flag?: string): string;
36
47
  /** How `sfora rooms` and `sfora join` spell a room name as an argument. */
package/dist/chat.js CHANGED
@@ -31,24 +31,71 @@ export function sanitizeClientSlug(raw) {
31
31
  const slug = raw.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 32);
32
32
  return slug || undefined;
33
33
  }
34
+ /**
35
+ * The client slugs sfora knows by name — the shared vocabulary with the web
36
+ * app's client marks. Unknown slugs stay valid everywhere; they just render
37
+ * the generic terminal glyph in the app.
38
+ */
39
+ export const KNOWN_CLIENTS = [
40
+ "claude-code",
41
+ "codex",
42
+ "cursor",
43
+ "gemini",
44
+ "windsurf",
45
+ "aider",
46
+ "opencode",
47
+ "cli",
48
+ ];
49
+ /**
50
+ * Env markers the coding agents actually set, checked in order.
51
+ *
52
+ * Only markers verified in the wild are listed — naming the wrong client is
53
+ * worse than falling through to the generic label, so windsurf / aider /
54
+ * opencode (no reliable marker known) rely on `--client` or `AI_AGENT`.
55
+ * Being set at all counts, even empty, matching the Claude Code rung below.
56
+ *
57
+ * CODEX_SANDBOX / CODEX_SANDBOX_NETWORK_DISABLED — OpenAI's Codex CLI sets
58
+ * these on the commands it runs.
59
+ * GEMINI_CLI — Google's Gemini CLI sets `GEMINI_CLI=1` in its shell tool.
60
+ * CURSOR_AGENT — Cursor's agent CLI names itself this way.
61
+ */
62
+ const CLIENT_MARKERS = [
63
+ ["CODEX_SANDBOX", "codex"],
64
+ ["CODEX_SANDBOX_NETWORK_DISABLED", "codex"],
65
+ ["GEMINI_CLI", "gemini"],
66
+ ["CURSOR_AGENT", "cursor"],
67
+ ];
34
68
  /**
35
69
  * What kind of client is on the line, for the presence label.
36
70
  *
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.
71
+ * Order: an explicit `--client` flag wins; then the per-client env markers in
72
+ * {@link CLIENT_MARKERS}; then `AI_AGENT` (the part before the first
73
+ * underscore — `claude-code_2-1-233_agent` names "claude-code"); then
74
+ * `CLAUDE_CODE_ENTRYPOINT` or `CLAUDECODE` being set at all means Claude Code
75
+ * is driving (kept below `AI_AGENT`, its long-standing rung); then
76
+ * `CURSOR_TRACE_ID` — set by Cursor's integrated TERMINAL and inherited by
77
+ * everything launched from it, so it sits at the bottom: Claude Code run
78
+ * inside Cursor must read as claude-code, not as the window around it. Else
79
+ * this is a plain terminal — "cli". Env is passed in, not read, so every rung
80
+ * is testable.
42
81
  */
43
82
  export function detectClient(env, flag) {
44
83
  const fromFlag = sanitizeClientSlug(flag);
45
84
  if (fromFlag)
46
85
  return fromFlag;
86
+ for (const [envVar, slug] of CLIENT_MARKERS) {
87
+ if (env[envVar] !== undefined)
88
+ return slug;
89
+ }
47
90
  const fromAgent = sanitizeClientSlug(env.AI_AGENT?.split("_")[0]);
48
91
  if (fromAgent)
49
92
  return fromAgent;
50
- if (env.CLAUDE_CODE_ENTRYPOINT !== undefined)
93
+ if (env.CLAUDE_CODE_ENTRYPOINT !== undefined ||
94
+ env.CLAUDECODE !== undefined) {
51
95
  return "claude-code";
96
+ }
97
+ if (env.CURSOR_TRACE_ID !== undefined)
98
+ return "cursor";
52
99
  return "cli";
53
100
  }
54
101
  // ─── Room resolution ─────────────────────────────────────────────────
@@ -26,8 +26,12 @@ export interface CliArgs {
26
26
  block?: string;
27
27
  self: boolean;
28
28
  wait?: number;
29
+ waitFlag: boolean;
29
30
  message?: string;
30
31
  limit?: number;
31
32
  client?: string;
33
+ option: string[];
34
+ target?: string;
35
+ room?: string;
32
36
  }
33
37
  export declare function parseArgs(argv: string[]): CliArgs;
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 };
9
+ const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false, waitFlag: false, option: [] };
10
10
  for (let i = 0; i < argv.length; i++) {
11
11
  const a = argv[i];
12
12
  if (a === "--mcp")
@@ -65,10 +65,22 @@ export function parseArgs(argv) {
65
65
  args.block = a.slice("--block=".length);
66
66
  else if (a === "--self")
67
67
  args.self = true;
68
- else if (a === "--wait")
69
- args.wait = Number.parseInt(argv[++i] ?? "", 10);
70
- else if (a.startsWith("--wait="))
68
+ // `--wait` doubles as a bare flag (`ask --wait` blocks until the answer)
69
+ // and a valued one (`watch --wait 10`, `ask --wait 300`). The next token
70
+ // is consumed only when it is a number, so `ask --wait --json` keeps its
71
+ // `--json` — the historical "missing value means NaN" shape survives as
72
+ // `wait: undefined` (every reader already treats non-finite as unset).
73
+ else if (a === "--wait") {
74
+ args.waitFlag = true;
75
+ const next = argv[i + 1];
76
+ if (next !== undefined && /^\d+$/.test(next)) {
77
+ args.wait = Number.parseInt(argv[++i], 10);
78
+ }
79
+ }
80
+ else if (a.startsWith("--wait=")) {
81
+ args.waitFlag = true;
71
82
  args.wait = Number.parseInt(a.slice("--wait=".length), 10);
83
+ }
72
84
  else if (a === "-m" || a === "--message")
73
85
  args.message = argv[++i];
74
86
  else if (a.startsWith("--message="))
@@ -81,6 +93,21 @@ export function parseArgs(argv) {
81
93
  args.client = argv[++i];
82
94
  else if (a.startsWith("--client="))
83
95
  args.client = a.slice("--client=".length);
96
+ else if (a === "--option") {
97
+ const value = argv[++i];
98
+ if (value !== undefined)
99
+ args.option.push(value);
100
+ }
101
+ else if (a.startsWith("--option="))
102
+ args.option.push(a.slice("--option=".length));
103
+ else if (a === "--for")
104
+ args.target = argv[++i];
105
+ else if (a.startsWith("--for="))
106
+ args.target = a.slice("--for=".length);
107
+ else if (a === "--room")
108
+ args.room = argv[++i];
109
+ else if (a.startsWith("--room="))
110
+ args.room = a.slice("--room=".length);
84
111
  else if (!a.startsWith("-")) {
85
112
  if (!args.command)
86
113
  args.command = a;
package/dist/cli.js CHANGED
@@ -11,7 +11,8 @@ import { spawn } from "node:child_process";
11
11
  import { readFile as readLocalFile } from "node:fs/promises";
12
12
  import { basename } from "node:path";
13
13
  import { taskUploadFilename } from "./format/taskUploadFilename.js";
14
- import { createSforaShell, createLocalShell, } from "./index.js";
14
+ import { createSforaShell, createLocalShell, SforaApiError, } from "./index.js";
15
+ import { askWaitLoop, reshapeCandidatesError, validateAskOptions, } from "./ask.js";
15
16
  import { openerCommand } from "./opener.js";
16
17
  import { blocksCommand, presenceNotice, putCommand, } from "./block-commands.js";
17
18
  import { colors, ndjson, presenceRecords, renderPresence, renderWriteEffect, urlLine, PRESENCE_NOTE, } from "./render.js";
@@ -20,7 +21,7 @@ import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, }
20
21
  import { runMcpServer } from "./mcp-server.js";
21
22
  import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
22
23
  import { parseArgs } from "./cli-args.js";
23
- import { chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
24
+ import { capitalizeName, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
24
25
  const HELP = `sfora — the CLI for your sfora workspace
25
26
 
26
27
  Get started (no account needed):
@@ -70,8 +71,21 @@ Chat:
70
71
  and agents)
71
72
 
72
73
  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.
74
+ and a coding agent is named automatically (Claude Code, Codex, Cursor and
75
+ Gemini set their own environment). Add --client <name> to say it yourself.
76
+
77
+ Ask a human:
78
+ sfora ask "<question>" --option "A" --option "B"
79
+ Post a question with 2–4 answers to
80
+ pick from — only humans can answer
81
+ sfora ask "…" --option … --for <member>
82
+ Aim it at one person (any human may
83
+ still answer)
84
+ sfora ask "…" --option … --wait [secs]
85
+ Block until someone answers, then print
86
+ the choice (give secs to stop waiting)
87
+ Add --project <slug> or --room <room> to say where it belongs; --json for
88
+ scripts (--wait prints a second JSON line when the answer lands).
75
89
 
76
90
  Write, and watch others write:
77
91
  sfora blocks <path> List a document's addressable blocks
@@ -366,6 +380,7 @@ const VERBS = new Set([
366
380
  "rooms",
367
381
  "join",
368
382
  "chat",
383
+ "ask",
369
384
  ]);
370
385
  async function runVerb(args, fs, client) {
371
386
  const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
@@ -528,6 +543,9 @@ async function runVerb(args, fs, client) {
528
543
  }
529
544
  return runChat(args, client);
530
545
  }
546
+ if (args.command === "ask") {
547
+ return runAsk(args, client);
548
+ }
531
549
  if (args.command === "me" || args.command === "whoami") {
532
550
  const text = (await client.readMe()).trim();
533
551
  if (args.json) {
@@ -818,7 +836,7 @@ async function runChat(args, client) {
818
836
  throw new Error('usage: sfora chat <room> -m "text"');
819
837
  // One beat alongside the send — a script that speaks was here, briefly.
820
838
  void beat();
821
- const { messageId } = await client.sendRoomMessage(room._id, text);
839
+ const { messageId } = await client.sendRoomMessage(room._id, text, clientLabel);
822
840
  if (args.json) {
823
841
  console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
824
842
  return;
@@ -909,7 +927,7 @@ async function runChat(args, client) {
909
927
  try {
910
928
  // The send's own echo is the typed line still on screen; recording the
911
929
  // id keeps the tail's refetch from printing it a second time.
912
- const { messageId } = await client.sendRoomMessage(room._id, line);
930
+ const { messageId } = await client.sendRoomMessage(room._id, line, clientLabel);
913
931
  seen.add(messageId);
914
932
  }
915
933
  catch (e) {
@@ -930,6 +948,154 @@ async function runChat(args, client) {
930
948
  if (isTty)
931
949
  console.log("");
932
950
  }
951
+ /**
952
+ * `sfora ask` — how an agent asks a human, wired to the real process.
953
+ *
954
+ * With `--option` flags the ask is a QUESTION: 2–4 short answers a human
955
+ * picks from in the app — only humans can answer, and the server enforces
956
+ * that in the mutation, not the UI. `--for` aims it at one person by name
957
+ * (resolved server-side; any human may still answer). Everything
958
+ * decision-shaped (validation, the wait loop, the ambiguity reformat) lives
959
+ * in `ask.ts` where it is tested; this owns signals and the streams.
960
+ */
961
+ async function runAsk(args, client) {
962
+ const text = args.rest.join(" ").trim();
963
+ if (!text) {
964
+ throw new Error('usage: sfora ask "<question>" --option "A" --option "B" ' +
965
+ "[--for <member>] [--project <slug>] [--room <room>] [--wait [secs]] [--json]");
966
+ }
967
+ const options = args.option.length > 0 ? validateAskOptions(args.option) : undefined;
968
+ let roomId;
969
+ let roomTag;
970
+ if (args.room) {
971
+ const room = await resolveRoomOrThrow(client, args.room);
972
+ roomId = room._id;
973
+ roomTag = `#${roomSlug(room.name)}`;
974
+ }
975
+ // The wait cursor is taken BEFORE the create, with generous skew slack: an
976
+ // answer landing between the create and the first poll must not fall into a
977
+ // blind window, and overlap is harmless — the loop matches on this ask's id
978
+ // and resolved state only.
979
+ const since = Date.now() - 5 * 60_000;
980
+ let created;
981
+ try {
982
+ created = await client.createAsk({
983
+ text,
984
+ options,
985
+ target: args.target,
986
+ project: args.project,
987
+ roomId,
988
+ });
989
+ }
990
+ catch (e) {
991
+ // An ambiguous `--for` comes back as a 400 listing the candidates on one
992
+ // line — reshape it to the say-which layout `sfora join` uses for rooms.
993
+ if (e instanceof SforaApiError && e.status === 400) {
994
+ throw new Error(reshapeCandidatesError(e.message));
995
+ }
996
+ throw e;
997
+ }
998
+ const { url } = client.takeResponseInfo();
999
+ const where = [
1000
+ args.project ? `project ${args.project}` : undefined,
1001
+ roomTag,
1002
+ ]
1003
+ .filter(Boolean)
1004
+ .join(" · ");
1005
+ if (args.json) {
1006
+ // NDJSON-shaped under --wait: this line says it landed; a second line
1007
+ // (below) says how it resolved. A script reads each as it arrives.
1008
+ console.log(JSON.stringify({
1009
+ askId: created.askId,
1010
+ ...(url ? { url } : {}),
1011
+ ...(options ? { options } : {}),
1012
+ ...(args.target ? { for: args.target } : {}),
1013
+ }));
1014
+ }
1015
+ else {
1016
+ console.log(`${colors.green}✓${colors.reset} Asked${where ? ` in ${where}` : ""} · ${created.askId}`);
1017
+ if (options) {
1018
+ console.log(` ${colors.dim}options: ${options.join(" · ")}${colors.reset}`);
1019
+ }
1020
+ if (args.target) {
1021
+ console.log(` ${colors.dim}for ${capitalizeName(args.target)} — only a human can answer${colors.reset}`);
1022
+ }
1023
+ if (url)
1024
+ process.stderr.write(` ${urlLine(url)}\n`);
1025
+ }
1026
+ if (!args.waitFlag)
1027
+ return;
1028
+ // `--wait [secs]`: block on the events long-poll until a human answers.
1029
+ // ^C has to reach the socket, same as `watch`: the poll hangs for tens of
1030
+ // seconds, and a flag alone would leave a terminal that says it stopped and
1031
+ // hasn't. Second ^C stands down and lets the default behaviour end things.
1032
+ const deadlineMs = Number.isFinite(args.wait) && args.wait > 0
1033
+ ? Date.now() + args.wait * 1000
1034
+ : undefined;
1035
+ let stop = false;
1036
+ const inFlight = new AbortController();
1037
+ let signalled = false;
1038
+ const onSignal = (signal) => {
1039
+ stop = true;
1040
+ inFlight.abort();
1041
+ if (signalled) {
1042
+ process.off("SIGINT", onSignal);
1043
+ process.off("SIGTERM", onSignal);
1044
+ process.kill(process.pid, signal);
1045
+ return;
1046
+ }
1047
+ signalled = true;
1048
+ };
1049
+ process.on("SIGINT", onSignal);
1050
+ process.on("SIGTERM", onSignal);
1051
+ if (!args.json) {
1052
+ process.stderr.write(`${colors.dim}waiting for an answer${deadlineMs ? ` (up to ${args.wait}s)` : ""} — ^C to stop${colors.reset}\n`);
1053
+ }
1054
+ try {
1055
+ const result = await askWaitLoop({
1056
+ // With a deadline, cap each poll's server-side budget to what is left
1057
+ // so the last poll returns near the deadline instead of long after it.
1058
+ poll: (cursor) => client.pollEvents({
1059
+ since: cursor,
1060
+ wait: deadlineMs
1061
+ ? Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))
1062
+ : undefined,
1063
+ signal: inFlight.signal,
1064
+ }),
1065
+ warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
1066
+ sleep,
1067
+ }, { askId: created.askId, since, deadlineMs, stopped: () => stop });
1068
+ if (result.outcome === "answered") {
1069
+ const answer = result.chosenOption ?? result.resolution;
1070
+ if (args.json) {
1071
+ console.log(JSON.stringify({
1072
+ askId: created.askId,
1073
+ state: "resolved",
1074
+ chosenOption: result.chosenOption ?? null,
1075
+ resolution: result.resolution ?? null,
1076
+ }));
1077
+ }
1078
+ else {
1079
+ console.log(`${colors.green}✓${colors.reset} Answered${answer ? `: ${answer}` : ""}`);
1080
+ }
1081
+ }
1082
+ else if (result.outcome === "timeout") {
1083
+ if (args.json) {
1084
+ console.log(JSON.stringify({ askId: created.askId, state: "open", timedOut: true }));
1085
+ }
1086
+ else {
1087
+ process.stderr.write(`${colors.dim}no answer within ${args.wait}s — the ask stays open${colors.reset}\n`);
1088
+ }
1089
+ // A script waiting on an answer needs to branch on not getting one.
1090
+ process.exitCode = 1;
1091
+ }
1092
+ // stopped (^C): say nothing — the ask stays open and the app still has it.
1093
+ }
1094
+ finally {
1095
+ process.off("SIGINT", onSignal);
1096
+ process.off("SIGTERM", onSignal);
1097
+ }
1098
+ }
933
1099
  /**
934
1100
  * A shared command's result, on the real streams.
935
1101
  *
package/dist/index.d.ts CHANGED
@@ -69,3 +69,5 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, t
69
69
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
70
70
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type WatchOptions, type WatchTarget, } from "./watch.js";
71
71
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
72
+ export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
73
+ 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
@@ -57,3 +57,5 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, }
57
57
  export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
58
58
  export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
59
59
  export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
60
+ export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
61
+ export { validateAskOptions, reshapeCandidatesError, askWaitLoop, ASK_OPTIONS_MIN, ASK_OPTIONS_MAX, ASK_OPTION_MAX_LENGTH, } from "./ask.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sfora-cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.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": [