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.
- package/dist/api-client.d.ts +22 -1
- package/dist/api-client.js +24 -2
- package/dist/ask.d.ts +75 -0
- package/dist/ask.js +119 -0
- package/dist/chat.d.ts +16 -5
- package/dist/chat.js +53 -6
- package/dist/cli-args.d.ts +4 -0
- package/dist/cli-args.js +31 -4
- package/dist/cli.js +172 -6
- 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
|
@@ -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
|
|
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). */
|
package/dist/api-client.js
CHANGED
|
@@ -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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 ─────────────────────────────────────────────────
|
package/dist/cli-args.d.ts
CHANGED
|
@@ -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
|
-
|
|
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="))
|
|
@@ -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
|
|
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.
|
|
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": [
|