sfora-cli 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +31 -0
- package/dist/api-client.js +31 -0
- package/dist/ask.d.ts +75 -0
- package/dist/ask.js +119 -0
- package/dist/chat.d.ts +27 -0
- package/dist/chat.js +79 -0
- package/dist/cli-args.d.ts +5 -0
- package/dist/cli-args.js +35 -4
- package/dist/cli.js +190 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -576,6 +576,37 @@ 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>;
|
|
591
|
+
/**
|
|
592
|
+
* `POST /api/asks` — post a coordination ask, or (with `options`) a
|
|
593
|
+
* QUESTION: 2–4 short answers for a human to pick from. `target` is a
|
|
594
|
+
* member NAME (or id), resolved server-side; an ambiguous name comes back
|
|
595
|
+
* as a 400 listing the candidates. Only humans can answer a question —
|
|
596
|
+
* enforced in the mutation, not here.
|
|
597
|
+
*/
|
|
598
|
+
createAsk(params: {
|
|
599
|
+
text: string;
|
|
600
|
+
options?: string[];
|
|
601
|
+
/** Member name or id the question is for — a human. */
|
|
602
|
+
target?: string;
|
|
603
|
+
/** Project slug the ask belongs to. */
|
|
604
|
+
project?: string;
|
|
605
|
+
/** Room to announce the ask into. */
|
|
606
|
+
roomId?: string;
|
|
607
|
+
}): Promise<{
|
|
608
|
+
askId: string;
|
|
609
|
+
}>;
|
|
579
610
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
580
611
|
readInbox(): Promise<string>;
|
|
581
612
|
/** `GET /v1/fs/me/api-key` — text identity (no key material). */
|
package/dist/api-client.js
CHANGED
|
@@ -637,6 +637,37 @@ 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
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* `POST /api/asks` — post a coordination ask, or (with `options`) a
|
|
653
|
+
* QUESTION: 2–4 short answers for a human to pick from. `target` is a
|
|
654
|
+
* member NAME (or id), resolved server-side; an ambiguous name comes back
|
|
655
|
+
* as a 400 listing the candidates. Only humans can answer a question —
|
|
656
|
+
* enforced in the mutation, not here.
|
|
657
|
+
*/
|
|
658
|
+
async createAsk(params) {
|
|
659
|
+
const body = { text: params.text };
|
|
660
|
+
if (params.options)
|
|
661
|
+
body.options = params.options;
|
|
662
|
+
if (params.target)
|
|
663
|
+
body.target = params.target;
|
|
664
|
+
if (params.project)
|
|
665
|
+
body.project = params.project;
|
|
666
|
+
if (params.roomId)
|
|
667
|
+
body.roomId = params.roomId;
|
|
668
|
+
const res = await this.#request("POST", "/api/asks", JSON.stringify(body), undefined, "application/json");
|
|
669
|
+
return this.#jsonFrom(res);
|
|
670
|
+
}
|
|
640
671
|
/** `GET /v1/fs/inbox/mentions.md` — markdown summary of unread mentions. */
|
|
641
672
|
async readInbox() {
|
|
642
673
|
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
|
@@ -17,6 +17,33 @@
|
|
|
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
|
+
* 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"];
|
|
32
|
+
/**
|
|
33
|
+
* What kind of client is on the line, for the presence label.
|
|
34
|
+
*
|
|
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.
|
|
45
|
+
*/
|
|
46
|
+
export declare function detectClient(env: Record<string, string | undefined>, flag?: string): string;
|
|
20
47
|
/** How `sfora rooms` and `sfora join` spell a room name as an argument. */
|
|
21
48
|
export declare function roomSlug(name: string): string;
|
|
22
49
|
export type RoomMatch = {
|
package/dist/chat.js
CHANGED
|
@@ -19,6 +19,85 @@
|
|
|
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
|
+
* 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
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* What kind of client is on the line, for the presence label.
|
|
70
|
+
*
|
|
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.
|
|
81
|
+
*/
|
|
82
|
+
export function detectClient(env, flag) {
|
|
83
|
+
const fromFlag = sanitizeClientSlug(flag);
|
|
84
|
+
if (fromFlag)
|
|
85
|
+
return fromFlag;
|
|
86
|
+
for (const [envVar, slug] of CLIENT_MARKERS) {
|
|
87
|
+
if (env[envVar] !== undefined)
|
|
88
|
+
return slug;
|
|
89
|
+
}
|
|
90
|
+
const fromAgent = sanitizeClientSlug(env.AI_AGENT?.split("_")[0]);
|
|
91
|
+
if (fromAgent)
|
|
92
|
+
return fromAgent;
|
|
93
|
+
if (env.CLAUDE_CODE_ENTRYPOINT !== undefined ||
|
|
94
|
+
env.CLAUDECODE !== undefined) {
|
|
95
|
+
return "claude-code";
|
|
96
|
+
}
|
|
97
|
+
if (env.CURSOR_TRACE_ID !== undefined)
|
|
98
|
+
return "cursor";
|
|
99
|
+
return "cli";
|
|
100
|
+
}
|
|
22
101
|
// ─── Room resolution ─────────────────────────────────────────────────
|
|
23
102
|
/** How `sfora rooms` and `sfora join` spell a room name as an argument. */
|
|
24
103
|
export function roomSlug(name) {
|
package/dist/cli-args.d.ts
CHANGED
|
@@ -26,7 +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;
|
|
32
|
+
client?: string;
|
|
33
|
+
option: string[];
|
|
34
|
+
target?: string;
|
|
35
|
+
room?: string;
|
|
31
36
|
}
|
|
32
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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="))
|
|
@@ -77,6 +89,25 @@ export function parseArgs(argv) {
|
|
|
77
89
|
args.limit = Number.parseInt(argv[++i] ?? "", 10);
|
|
78
90
|
else if (a.startsWith("--limit="))
|
|
79
91
|
args.limit = Number.parseInt(a.slice("--limit=".length), 10);
|
|
92
|
+
else if (a === "--client")
|
|
93
|
+
args.client = argv[++i];
|
|
94
|
+
else if (a.startsWith("--client="))
|
|
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);
|
|
80
111
|
else if (!a.startsWith("-")) {
|
|
81
112
|
if (!args.command)
|
|
82
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, 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):
|
|
@@ -69,6 +70,23 @@ Chat:
|
|
|
69
70
|
sfora chat <room> -m "text" Send one message and exit (for scripts
|
|
70
71
|
and agents)
|
|
71
72
|
|
|
73
|
+
Chat shows others what's on the line: the CLI reports itself in presence,
|
|
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).
|
|
89
|
+
|
|
72
90
|
Write, and watch others write:
|
|
73
91
|
sfora blocks <path> List a document's addressable blocks
|
|
74
92
|
sfora put <path> <file.md> Write a file (add --block <id> for one block)
|
|
@@ -362,6 +380,7 @@ const VERBS = new Set([
|
|
|
362
380
|
"rooms",
|
|
363
381
|
"join",
|
|
364
382
|
"chat",
|
|
383
|
+
"ask",
|
|
365
384
|
]);
|
|
366
385
|
async function runVerb(args, fs, client) {
|
|
367
386
|
const ok = (msg) => console.log(`${colors.green}✓${colors.reset} ${msg}`);
|
|
@@ -524,6 +543,9 @@ async function runVerb(args, fs, client) {
|
|
|
524
543
|
}
|
|
525
544
|
return runChat(args, client);
|
|
526
545
|
}
|
|
546
|
+
if (args.command === "ask") {
|
|
547
|
+
return runAsk(args, client);
|
|
548
|
+
}
|
|
527
549
|
if (args.command === "me" || args.command === "whoami") {
|
|
528
550
|
const text = (await client.readMe()).trim();
|
|
529
551
|
if (args.json) {
|
|
@@ -801,11 +823,19 @@ async function runChat(args, client) {
|
|
|
801
823
|
await client.joinRoom(room._id);
|
|
802
824
|
console.log(`${colors.green}✓${colors.reset} Joined ${tag}`);
|
|
803
825
|
}
|
|
826
|
+
// Presence is decoration: a beat that fails changes nothing about the
|
|
827
|
+
// conversation, so every beat here is best-effort and silent. The label
|
|
828
|
+
// says WHAT is on the line — "cli", or "claude-code" when a coding agent
|
|
829
|
+
// is driving — so the app can show the terminal next to the online dot.
|
|
830
|
+
const clientLabel = detectClient(process.env, args.client);
|
|
831
|
+
const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
|
|
804
832
|
// One-shot send.
|
|
805
833
|
if (args.message !== undefined) {
|
|
806
834
|
const text = args.message.trim();
|
|
807
835
|
if (!text)
|
|
808
836
|
throw new Error('usage: sfora chat <room> -m "text"');
|
|
837
|
+
// One beat alongside the send — a script that speaks was here, briefly.
|
|
838
|
+
void beat();
|
|
809
839
|
const { messageId } = await client.sendRoomMessage(room._id, text);
|
|
810
840
|
if (args.json) {
|
|
811
841
|
console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
|
|
@@ -831,6 +861,12 @@ async function runChat(args, client) {
|
|
|
831
861
|
for (const msg of messages)
|
|
832
862
|
console.log(renderChatMessage(msg, Date.now()));
|
|
833
863
|
process.stderr.write(`${colors.dim}${tag} — type to send · /quit (or ^C) to leave${colors.reset}\n`);
|
|
864
|
+
// Sitting in the room is being present in it: beat now, then every ~45s
|
|
865
|
+
// (presence expires server-side, so the cadence just has to outrun the
|
|
866
|
+
// timeout). `unref` so the timer never keeps the process alive on its own.
|
|
867
|
+
void beat();
|
|
868
|
+
const pulse = setInterval(() => void beat(), 45_000);
|
|
869
|
+
pulse.unref?.();
|
|
834
870
|
// The prompt and the tail share one screen: an arriving message clears the
|
|
835
871
|
// prompt line, prints, and readline redraws the prompt with whatever was
|
|
836
872
|
// being typed. `readline.clearLine` over anything fancier — robust beats
|
|
@@ -904,10 +940,162 @@ async function runChat(args, client) {
|
|
|
904
940
|
stop = true;
|
|
905
941
|
inFlight.abort();
|
|
906
942
|
rl.close();
|
|
943
|
+
clearInterval(pulse);
|
|
944
|
+
// A parting beat, best-effort — awaited so quitting doesn't race process
|
|
945
|
+
// exit, but never allowed to hold the door.
|
|
946
|
+
await beat();
|
|
907
947
|
await tail.catch(() => { });
|
|
908
948
|
if (isTty)
|
|
909
949
|
console.log("");
|
|
910
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
|
+
}
|
|
911
1099
|
/**
|
|
912
1100
|
* A shared command's result, on the real streams.
|
|
913
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.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Your sfora workspace as a markdown filesystem — a CLI + MCP server. Post/task/doc, ls/cat/grep, and a shell so agents operate sfora natively.",
|
|
6
6
|
"keywords": [
|