sfora-cli 0.13.0 → 0.14.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 +23 -1
- package/dist/api-client.js +16 -52
- package/dist/chat.d.ts +55 -0
- package/dist/chat.js +75 -0
- package/dist/cli-args.d.ts +3 -0
- package/dist/cli-args.js +9 -1
- package/dist/cli.js +227 -11
- package/dist/index.d.ts +7 -1
- package/dist/index.js +2 -1
- package/dist/mcp-server.d.ts +2 -0
- package/dist/mcp-server.js +1 -0
- package/package.json +1 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -260,6 +260,12 @@ export interface ChatMessage {
|
|
|
260
260
|
_creationTime: number;
|
|
261
261
|
/** Null when the author's member row is gone. */
|
|
262
262
|
author: ChatAuthor | null;
|
|
263
|
+
/**
|
|
264
|
+
* The client slug the message was sent through ("claude-code"), when the
|
|
265
|
+
* sending door stamped one. Absent on app sends and on servers that do not
|
|
266
|
+
* surface it yet.
|
|
267
|
+
*/
|
|
268
|
+
sentVia?: string;
|
|
263
269
|
}
|
|
264
270
|
/** A page of messages, NEWEST FIRST — the server paginates backwards. */
|
|
265
271
|
export interface MessagesPage {
|
|
@@ -297,6 +303,14 @@ export interface SforaApiConfig {
|
|
|
297
303
|
* key per agent.
|
|
298
304
|
*/
|
|
299
305
|
actAs?: string;
|
|
306
|
+
/**
|
|
307
|
+
* What kind of client is on the line — a short slug ("claude-code", "cli").
|
|
308
|
+
* Sent as `X-Sfora-Client` on every request so creation doors can stamp the
|
|
309
|
+
* things they make ("sent via Claude Code" on a message or post). Sent
|
|
310
|
+
* always, including the plain "cli" — a terminal is honest attribution too.
|
|
311
|
+
* The server sanitizes it and ignores it where it means nothing.
|
|
312
|
+
*/
|
|
313
|
+
clientLabel?: string;
|
|
300
314
|
}
|
|
301
315
|
/** Non-2xx response from the fs API. Carries the HTTP status + machine code so SforaFs can map it to an errno. */
|
|
302
316
|
export declare class SforaApiError extends Error {
|
|
@@ -550,6 +564,12 @@ export declare class SforaApiClient {
|
|
|
550
564
|
doc?: string;
|
|
551
565
|
project?: string;
|
|
552
566
|
includeSelf?: boolean;
|
|
567
|
+
/**
|
|
568
|
+
* Deliver message events for the caller's OWN messages too — how a tail
|
|
569
|
+
* hears the same member speaking from the app. Distinct from
|
|
570
|
+
* `includeSelf`, which is about document writes.
|
|
571
|
+
*/
|
|
572
|
+
includeOwn?: boolean;
|
|
553
573
|
signal?: AbortSignal;
|
|
554
574
|
}): Promise<AgentEventsPage>;
|
|
555
575
|
/**
|
|
@@ -573,7 +593,9 @@ export declare class SforaApiClient {
|
|
|
573
593
|
/** `GET /api/rooms/:id/messages` — history, newest first (server cap: 100). */
|
|
574
594
|
listRoomMessages(roomId: string, limit?: number): Promise<MessagesPage>;
|
|
575
595
|
/** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
|
|
576
|
-
sendRoomMessage(roomId: string, body: string
|
|
596
|
+
sendRoomMessage(roomId: string, body: string,
|
|
597
|
+
/** Terminal client slug — stamped on the message ("sent via Claude Code"). */
|
|
598
|
+
client?: string): Promise<{
|
|
577
599
|
messageId: string;
|
|
578
600
|
}>;
|
|
579
601
|
/**
|
package/dist/api-client.js
CHANGED
|
@@ -89,6 +89,7 @@ export class SforaApiClient {
|
|
|
89
89
|
#baseUrl;
|
|
90
90
|
#apiKey;
|
|
91
91
|
#actAs;
|
|
92
|
+
#clientLabel;
|
|
92
93
|
/**
|
|
93
94
|
* What the LAST response said about itself, beyond the data it carried.
|
|
94
95
|
*
|
|
@@ -108,6 +109,7 @@ export class SforaApiClient {
|
|
|
108
109
|
this.#baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
109
110
|
this.#apiKey = config.apiKey;
|
|
110
111
|
this.#actAs = config.actAs;
|
|
112
|
+
this.#clientLabel = config.clientLabel;
|
|
111
113
|
}
|
|
112
114
|
/** What the last response said about itself, consumed. */
|
|
113
115
|
takeResponseInfo() {
|
|
@@ -148,6 +150,8 @@ export class SforaApiClient {
|
|
|
148
150
|
headers.Authorization = `Bearer ${this.#apiKey}`;
|
|
149
151
|
if (this.#actAs)
|
|
150
152
|
headers["X-Sfora-Act-As"] = this.#actAs;
|
|
153
|
+
if (this.#clientLabel)
|
|
154
|
+
headers["X-Sfora-Client"] = this.#clientLabel;
|
|
151
155
|
if (body !== undefined)
|
|
152
156
|
headers["Content-Type"] = contentType;
|
|
153
157
|
let res;
|
|
@@ -445,16 +449,7 @@ export class SforaApiClient {
|
|
|
445
449
|
const slug = encodeURIComponent(projectSlug);
|
|
446
450
|
// The server slugifies `name` to derive the directory; we send it as a
|
|
447
451
|
// single-field JSON body to keep the spec consistent with future fields.
|
|
448
|
-
const res = await
|
|
449
|
-
method: "POST",
|
|
450
|
-
headers: {
|
|
451
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
452
|
-
"Content-Type": "application/json",
|
|
453
|
-
},
|
|
454
|
-
body: JSON.stringify({ name }),
|
|
455
|
-
});
|
|
456
|
-
if (!res.ok)
|
|
457
|
-
throw await this.#toError(res);
|
|
452
|
+
const res = await this.#request("POST", `/v1/fs/projects/${slug}/board`, JSON.stringify({ name }), undefined, "application/json");
|
|
458
453
|
return await this.#jsonFrom(res);
|
|
459
454
|
}
|
|
460
455
|
/**
|
|
@@ -464,16 +459,7 @@ export class SforaApiClient {
|
|
|
464
459
|
async renameColumn(projectSlug, columnDir, newName) {
|
|
465
460
|
const slug = encodeURIComponent(projectSlug);
|
|
466
461
|
const col = encodeURIComponent(columnDir);
|
|
467
|
-
const res = await
|
|
468
|
-
method: "POST",
|
|
469
|
-
headers: {
|
|
470
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
471
|
-
"Content-Type": "application/json",
|
|
472
|
-
},
|
|
473
|
-
body: JSON.stringify({ name: newName }),
|
|
474
|
-
});
|
|
475
|
-
if (!res.ok)
|
|
476
|
-
throw await this.#toError(res);
|
|
462
|
+
const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${col}/_rename`, JSON.stringify({ name: newName }), undefined, "application/json");
|
|
477
463
|
return await this.#jsonFrom(res);
|
|
478
464
|
}
|
|
479
465
|
/** `DELETE …/board/:column` — column must be empty. */
|
|
@@ -490,31 +476,13 @@ export class SforaApiClient {
|
|
|
490
476
|
const slug = encodeURIComponent(projectSlug);
|
|
491
477
|
const from = encodeURIComponent(fromCol);
|
|
492
478
|
const file = encodeURIComponent(filename);
|
|
493
|
-
const res = await
|
|
494
|
-
method: "POST",
|
|
495
|
-
headers: {
|
|
496
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
497
|
-
"Content-Type": "application/json",
|
|
498
|
-
},
|
|
499
|
-
body: JSON.stringify({ toColumn: toCol }),
|
|
500
|
-
});
|
|
501
|
-
if (!res.ok)
|
|
502
|
-
throw await this.#toError(res);
|
|
479
|
+
const res = await this.#request("POST", `/v1/fs/projects/${slug}/board/${from}/${file}/_move`, JSON.stringify({ toColumn: toCol }), undefined, "application/json");
|
|
503
480
|
return await this.#jsonFrom(res);
|
|
504
481
|
}
|
|
505
482
|
// ─── Post actions (comment / react) ─────────────────────────────
|
|
506
483
|
/** `POST /v1/posts/:postId/comments` — add a comment to a post. */
|
|
507
484
|
async createComment(postId, body) {
|
|
508
|
-
const res = await
|
|
509
|
-
method: "POST",
|
|
510
|
-
headers: {
|
|
511
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
512
|
-
"Content-Type": "application/json",
|
|
513
|
-
},
|
|
514
|
-
body: JSON.stringify({ body }),
|
|
515
|
-
});
|
|
516
|
-
if (!res.ok)
|
|
517
|
-
throw await this.#toError(res);
|
|
485
|
+
const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/comments`, JSON.stringify({ body }), undefined, "application/json");
|
|
518
486
|
const data = await this.#jsonFrom(res);
|
|
519
487
|
return { id: data.comment?._id ?? "" };
|
|
520
488
|
}
|
|
@@ -523,16 +491,7 @@ export class SforaApiClient {
|
|
|
523
491
|
* Returns whether the reaction is now on (`reacted: true`) or off.
|
|
524
492
|
*/
|
|
525
493
|
async reactToPost(postId, emoji) {
|
|
526
|
-
const res = await
|
|
527
|
-
method: "POST",
|
|
528
|
-
headers: {
|
|
529
|
-
Authorization: `Bearer ${this.#apiKey}`,
|
|
530
|
-
"Content-Type": "application/json",
|
|
531
|
-
},
|
|
532
|
-
body: JSON.stringify({ emoji }),
|
|
533
|
-
});
|
|
534
|
-
if (!res.ok)
|
|
535
|
-
throw await this.#toError(res);
|
|
494
|
+
const res = await this.#request("POST", `/v1/posts/${encodeURIComponent(postId)}/reactions`, JSON.stringify({ emoji }), undefined, "application/json");
|
|
536
495
|
const data = await this.#jsonFrom(res);
|
|
537
496
|
return data.reaction ?? { content: emoji, reacted: true };
|
|
538
497
|
}
|
|
@@ -600,6 +559,9 @@ export class SforaApiClient {
|
|
|
600
559
|
// own writes, matching the message branch's "don't wake on your own".
|
|
601
560
|
if (params.includeSelf)
|
|
602
561
|
query.set("self", "include");
|
|
562
|
+
// `?includeOwn=1` — exactly "1", the server's spelling.
|
|
563
|
+
if (params.includeOwn)
|
|
564
|
+
query.set("includeOwn", "1");
|
|
603
565
|
return this.#json(`/v1/events?${query.toString()}`, params.signal);
|
|
604
566
|
}
|
|
605
567
|
/**
|
|
@@ -631,10 +593,12 @@ export class SforaApiClient {
|
|
|
631
593
|
return this.#json(`/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`);
|
|
632
594
|
}
|
|
633
595
|
/** `POST /api/rooms/:id/messages` with `{ body }` — send markdown. */
|
|
634
|
-
async sendRoomMessage(roomId, body
|
|
596
|
+
async sendRoomMessage(roomId, body,
|
|
597
|
+
/** Terminal client slug — stamped on the message ("sent via Claude Code"). */
|
|
598
|
+
client) {
|
|
635
599
|
// Through #request like every other call, so network failures surface as
|
|
636
600
|
// 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");
|
|
601
|
+
const res = await this.#request("POST", `/api/rooms/${encodeURIComponent(roomId)}/messages`, JSON.stringify({ body, client }), undefined, "application/json");
|
|
638
602
|
return this.#jsonFrom(res);
|
|
639
603
|
}
|
|
640
604
|
/**
|
package/dist/chat.d.ts
CHANGED
|
@@ -82,6 +82,14 @@ export declare function renderChatBody(markdown: string): string;
|
|
|
82
82
|
* context, not content.
|
|
83
83
|
*/
|
|
84
84
|
export declare function renderChatMessage(msg: ChatMessage, now: number): string;
|
|
85
|
+
/**
|
|
86
|
+
* One message as an NDJSON line — the machine half of `chat --follow --json`
|
|
87
|
+
* and `--await-reply --json`. A small, stable shape rather than the wire row:
|
|
88
|
+
* `{ author, body, at, sentVia? }`, `at` in ISO 8601. `sentVia` appears only
|
|
89
|
+
* when the sending door stamped one; the author's name is passed through raw —
|
|
90
|
+
* capitalization is a display rule, and this line is for a program.
|
|
91
|
+
*/
|
|
92
|
+
export declare function chatMessageJson(msg: ChatMessage): string;
|
|
85
93
|
/** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
|
|
86
94
|
export declare function renderRoomList(rooms: Room[]): string;
|
|
87
95
|
/** Everything the tail loop needs from the outside world. */
|
|
@@ -114,3 +122,50 @@ export interface ChatTailOptions {
|
|
|
114
122
|
* `seen` set is the single ledger, shared with the prompt's own sends.
|
|
115
123
|
*/
|
|
116
124
|
export declare function chatTailLoop(deps: ChatTailDeps, options: ChatTailOptions): Promise<number>;
|
|
125
|
+
/** Everything the reply wait needs from the outside world. */
|
|
126
|
+
export interface AwaitReplyDeps {
|
|
127
|
+
/**
|
|
128
|
+
* One `/v1/events` long-poll — the caller's poll passes `includeOwn`, so
|
|
129
|
+
* the same member answering from the app rings the doorbell too.
|
|
130
|
+
*/
|
|
131
|
+
poll(since: number): Promise<AgentEventsPage>;
|
|
132
|
+
/** The room's recent page, any order — refetched when the doorbell rings. */
|
|
133
|
+
fetchRecent(): Promise<ChatMessage[]>;
|
|
134
|
+
/** A warning, on stderr — never mixed into stdout. */
|
|
135
|
+
warn(text: string): void;
|
|
136
|
+
sleep(ms: number): Promise<void>;
|
|
137
|
+
/** The clock, injectable for tests. */
|
|
138
|
+
now?(): number;
|
|
139
|
+
}
|
|
140
|
+
export interface AwaitReplyOptions {
|
|
141
|
+
roomId: string;
|
|
142
|
+
/**
|
|
143
|
+
* Message ids that are NOT the reply: the history before the send, and the
|
|
144
|
+
* send itself. The reply is picked by id, not author — the same human
|
|
145
|
+
* answering from the app IS the reply.
|
|
146
|
+
*/
|
|
147
|
+
seen: Set<string>;
|
|
148
|
+
/** Events cursor to start from — BEFORE the send, on server time. */
|
|
149
|
+
since: number;
|
|
150
|
+
/** Absolute deadline (ms epoch); absent means wait forever. */
|
|
151
|
+
deadlineMs?: number;
|
|
152
|
+
stopped?: () => boolean;
|
|
153
|
+
backoffMs?: number;
|
|
154
|
+
}
|
|
155
|
+
export type AwaitReplyResult = {
|
|
156
|
+
outcome: "reply";
|
|
157
|
+
message: ChatMessage;
|
|
158
|
+
} | {
|
|
159
|
+
outcome: "timeout";
|
|
160
|
+
} | {
|
|
161
|
+
outcome: "stopped";
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Poll until the room speaks back, the deadline passes, or the caller stops.
|
|
165
|
+
*
|
|
166
|
+
* The same rules the tail holds — forward-only cursor, doubling backoff to
|
|
167
|
+
* {@link MAX_BACKOFF_MS}, the doorbell-then-refetch shape — but instead of
|
|
168
|
+
* printing forever, the FIRST unseen message ends the wait. Oldest wins when
|
|
169
|
+
* several land at once: the reply is the next thing said, not the latest.
|
|
170
|
+
*/
|
|
171
|
+
export declare function awaitReplyLoop(deps: AwaitReplyDeps, options: AwaitReplyOptions): Promise<AwaitReplyResult>;
|
package/dist/chat.js
CHANGED
|
@@ -199,6 +199,21 @@ export function renderChatMessage(msg, now) {
|
|
|
199
199
|
const header = `${colors.bold}${author}${colors.reset} ${dim(relativeTime(msg._creationTime, now))}`;
|
|
200
200
|
return `${header}\n${renderChatBody(msg.body)}`;
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* One message as an NDJSON line — the machine half of `chat --follow --json`
|
|
204
|
+
* and `--await-reply --json`. A small, stable shape rather than the wire row:
|
|
205
|
+
* `{ author, body, at, sentVia? }`, `at` in ISO 8601. `sentVia` appears only
|
|
206
|
+
* when the sending door stamped one; the author's name is passed through raw —
|
|
207
|
+
* capitalization is a display rule, and this line is for a program.
|
|
208
|
+
*/
|
|
209
|
+
export function chatMessageJson(msg) {
|
|
210
|
+
return JSON.stringify({
|
|
211
|
+
author: msg.author?.name ?? null,
|
|
212
|
+
body: msg.body,
|
|
213
|
+
at: new Date(msg._creationTime).toISOString(),
|
|
214
|
+
...(msg.sentVia ? { sentVia: msg.sentVia } : {}),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
202
217
|
/** `sfora rooms` — one row per room; unjoined rows carry their join hint. */
|
|
203
218
|
export function renderRoomList(rooms) {
|
|
204
219
|
if (rooms.length === 0)
|
|
@@ -266,3 +281,63 @@ export async function chatTailLoop(deps, options) {
|
|
|
266
281
|
}
|
|
267
282
|
return cursor;
|
|
268
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* Poll until the room speaks back, the deadline passes, or the caller stops.
|
|
286
|
+
*
|
|
287
|
+
* The same rules the tail holds — forward-only cursor, doubling backoff to
|
|
288
|
+
* {@link MAX_BACKOFF_MS}, the doorbell-then-refetch shape — but instead of
|
|
289
|
+
* printing forever, the FIRST unseen message ends the wait. Oldest wins when
|
|
290
|
+
* several land at once: the reply is the next thing said, not the latest.
|
|
291
|
+
*/
|
|
292
|
+
export async function awaitReplyLoop(deps, options) {
|
|
293
|
+
const stopped = options.stopped ?? (() => false);
|
|
294
|
+
const now = deps.now ?? Date.now;
|
|
295
|
+
const firstBackoff = options.backoffMs ?? 1_000;
|
|
296
|
+
let cursor = options.since;
|
|
297
|
+
let backoff = firstBackoff;
|
|
298
|
+
while (!stopped()) {
|
|
299
|
+
if (options.deadlineMs !== undefined && now() >= options.deadlineMs) {
|
|
300
|
+
return { outcome: "timeout" };
|
|
301
|
+
}
|
|
302
|
+
let page;
|
|
303
|
+
try {
|
|
304
|
+
page = await deps.poll(cursor);
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (stopped())
|
|
308
|
+
break;
|
|
309
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
310
|
+
deps.warn(`reconnecting in ${Math.round(backoff / 1000)}s — ${message}`);
|
|
311
|
+
await deps.sleep(backoff);
|
|
312
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
backoff = firstBackoff;
|
|
316
|
+
const rang = page.events.some((e) => e.type === "message" && e.roomId === options.roomId);
|
|
317
|
+
if (rang) {
|
|
318
|
+
let recent;
|
|
319
|
+
try {
|
|
320
|
+
recent = await deps.fetchRecent();
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
324
|
+
deps.warn(`could not fetch new messages — ${message}`);
|
|
325
|
+
// The doorbell rang and the refetch failed: do NOT advance the
|
|
326
|
+
// cursor, or the reply's event is consumed and the wait can hang
|
|
327
|
+
// forever if nothing else is ever said (reviewer MAJOR). Back off
|
|
328
|
+
// briefly and let the next poll re-deliver the same event.
|
|
329
|
+
await deps.sleep(backoff);
|
|
330
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const fresh = recent
|
|
334
|
+
.filter((m) => !options.seen.has(m._id))
|
|
335
|
+
.sort((a, b) => a._creationTime - b._creationTime);
|
|
336
|
+
if (fresh.length > 0)
|
|
337
|
+
return { outcome: "reply", message: fresh[0] };
|
|
338
|
+
}
|
|
339
|
+
// Never backwards: an empty page holds the cursor where it was.
|
|
340
|
+
cursor = Math.max(cursor, page.cursor);
|
|
341
|
+
}
|
|
342
|
+
return { outcome: "stopped" };
|
|
343
|
+
}
|
package/dist/cli-args.d.ts
CHANGED
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, waitFlag: false, option: [] };
|
|
9
|
+
const args = { rest: [], cwd: "/", mcp: false, help: false, draft: false, json: false, local: false, cloud: false, self: false, waitFlag: false, follow: false, awaitReply: false, option: [] };
|
|
10
10
|
for (let i = 0; i < argv.length; i++) {
|
|
11
11
|
const a = argv[i];
|
|
12
12
|
if (a === "--mcp")
|
|
@@ -89,6 +89,14 @@ export function parseArgs(argv) {
|
|
|
89
89
|
args.limit = Number.parseInt(argv[++i] ?? "", 10);
|
|
90
90
|
else if (a.startsWith("--limit="))
|
|
91
91
|
args.limit = Number.parseInt(a.slice("--limit=".length), 10);
|
|
92
|
+
else if (a === "--follow")
|
|
93
|
+
args.follow = true;
|
|
94
|
+
else if (a === "--await-reply")
|
|
95
|
+
args.awaitReply = true;
|
|
96
|
+
else if (a === "--timeout")
|
|
97
|
+
args.timeout = Number.parseInt(argv[++i] ?? "", 10);
|
|
98
|
+
else if (a.startsWith("--timeout="))
|
|
99
|
+
args.timeout = Number.parseInt(a.slice("--timeout=".length), 10);
|
|
92
100
|
else if (a === "--client")
|
|
93
101
|
args.client = argv[++i];
|
|
94
102
|
else if (a.startsWith("--client="))
|
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import { LocalWorkspace, initWorkspace, findWorkspace, migrateWorkspaceStages, }
|
|
|
21
21
|
import { runMcpServer } from "./mcp-server.js";
|
|
22
22
|
import { readConfig, writeConfig, resolveSettings, upsertProfile, effectiveProfiles, DEFAULT_URL, } from "./config.js";
|
|
23
23
|
import { parseArgs } from "./cli-args.js";
|
|
24
|
-
import { capitalizeName, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
|
|
24
|
+
import { awaitReplyLoop, capitalizeName, chatMessageJson, chatTailLoop, detectClient, renderChatMessage, renderRoomList, resolveRoomRef, roomSlug, } from "./chat.js";
|
|
25
25
|
const HELP = `sfora — the CLI for your sfora workspace
|
|
26
26
|
|
|
27
27
|
Get started (no account needed):
|
|
@@ -69,10 +69,18 @@ Chat:
|
|
|
69
69
|
new messages stream in live
|
|
70
70
|
sfora chat <room> -m "text" Send one message and exit (for scripts
|
|
71
71
|
and agents)
|
|
72
|
+
sfora chat <room> --follow Tail the room without a prompt — for
|
|
73
|
+
logs and pipes (--json for NDJSON)
|
|
74
|
+
sfora chat <room> -m "text" --await-reply
|
|
75
|
+
Send, then wait for the next message
|
|
76
|
+
back and print it — --timeout <secs>
|
|
77
|
+
stops waiting (exit code 2)
|
|
72
78
|
|
|
73
79
|
Chat shows others what's on the line: the CLI reports itself in presence,
|
|
74
80
|
and a coding agent is named automatically (Claude Code, Codex, Cursor and
|
|
75
81
|
Gemini set their own environment). Add --client <name> to say it yourself.
|
|
82
|
+
Everything the CLI creates — messages, posts, tasks, docs — carries the
|
|
83
|
+
same name, so the feed says what sent it.
|
|
76
84
|
|
|
77
85
|
Ask a human:
|
|
78
86
|
sfora ask "<question>" --option "A" --option "B"
|
|
@@ -178,6 +186,16 @@ async function runInit(args) {
|
|
|
178
186
|
console.log(`${colors.green}✓${colors.reset} Saved ${path} ${colors.dim}(${profile})${colors.reset}`);
|
|
179
187
|
}
|
|
180
188
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
189
|
+
/** A backoff sleep that a stop check can cut short: sliced into 250ms
|
|
190
|
+
* intervals so ^C during a 30s reconnect backoff exits promptly instead of
|
|
191
|
+
* waiting the sleep out (reviewer catch — --follow/--await-reply are
|
|
192
|
+
* unattended surfaces where a hung shutdown means a hung supervisor). */
|
|
193
|
+
const stoppableSleep = (stopped) => async (ms) => {
|
|
194
|
+
const until = Date.now() + ms;
|
|
195
|
+
while (Date.now() < until && !stopped()) {
|
|
196
|
+
await sleep(Math.min(250, until - Date.now()));
|
|
197
|
+
}
|
|
198
|
+
};
|
|
181
199
|
function openBrowser(url) {
|
|
182
200
|
const { command, args } = openerCommand(process.platform, url);
|
|
183
201
|
try {
|
|
@@ -539,7 +557,7 @@ async function runVerb(args, fs, client) {
|
|
|
539
557
|
}
|
|
540
558
|
if (args.command === "chat") {
|
|
541
559
|
if (!args.rest[0]) {
|
|
542
|
-
throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"]');
|
|
560
|
+
throw new Error('usage: sfora chat <room> [-n <count>] [-m "text"] [--follow] [--await-reply]');
|
|
543
561
|
}
|
|
544
562
|
return runChat(args, client);
|
|
545
563
|
}
|
|
@@ -767,7 +785,7 @@ async function runWatch(args, client, target) {
|
|
|
767
785
|
write: (text) => process.stdout.write(text),
|
|
768
786
|
// Warnings on stderr so `--json` stdout stays parseable NDJSON.
|
|
769
787
|
warn: (text) => process.stderr.write(`${colors.dim}${text}${colors.reset}\n`),
|
|
770
|
-
sleep,
|
|
788
|
+
sleep: stoppableSleep(() => stop),
|
|
771
789
|
}, { json: args.json, since, stopped: () => stop });
|
|
772
790
|
}
|
|
773
791
|
finally {
|
|
@@ -829,14 +847,17 @@ async function runChat(args, client) {
|
|
|
829
847
|
// is driving — so the app can show the terminal next to the online dot.
|
|
830
848
|
const clientLabel = detectClient(process.env, args.client);
|
|
831
849
|
const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
|
|
832
|
-
// One-shot send.
|
|
850
|
+
// One-shot send — with --await-reply, the wait for what comes back.
|
|
833
851
|
if (args.message !== undefined) {
|
|
834
852
|
const text = args.message.trim();
|
|
835
853
|
if (!text)
|
|
836
854
|
throw new Error('usage: sfora chat <room> -m "text"');
|
|
837
855
|
// One beat alongside the send — a script that speaks was here, briefly.
|
|
838
856
|
void beat();
|
|
839
|
-
|
|
857
|
+
if (args.awaitReply) {
|
|
858
|
+
return runSendAwaitReply(args, client, room, tag, text, clientLabel);
|
|
859
|
+
}
|
|
860
|
+
const { messageId } = await client.sendRoomMessage(room._id, text, clientLabel);
|
|
840
861
|
if (args.json) {
|
|
841
862
|
console.log(JSON.stringify({ messageId, roomId: room._id }, null, 2));
|
|
842
863
|
return;
|
|
@@ -844,6 +865,12 @@ async function runChat(args, client) {
|
|
|
844
865
|
console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
|
|
845
866
|
return;
|
|
846
867
|
}
|
|
868
|
+
if (args.awaitReply) {
|
|
869
|
+
throw new Error('usage: sfora chat <room> -m "text" --await-reply [--timeout <secs>] — the reply needs a message to reply to');
|
|
870
|
+
}
|
|
871
|
+
// The tail without the prompt — for logs, pipes, and agents.
|
|
872
|
+
if (args.follow)
|
|
873
|
+
return runChatFollow(args, client, room, tag, clientLabel);
|
|
847
874
|
// History, oldest first — the page arrives newest-first.
|
|
848
875
|
const limit = Number.isFinite(args.limit) && args.limit > 0
|
|
849
876
|
? Math.min(args.limit, 100)
|
|
@@ -854,6 +881,7 @@ async function runChat(args, client) {
|
|
|
854
881
|
const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
|
|
855
882
|
const seeded = history.page.slice().reverse();
|
|
856
883
|
const seen = new Set(seeded.map((m) => m._id));
|
|
884
|
+
const pendingSendBodies = new Set();
|
|
857
885
|
const messages = seeded.slice(-limit);
|
|
858
886
|
if (messages.length === 0) {
|
|
859
887
|
console.log(`${colors.dim}(no messages yet)${colors.reset}`);
|
|
@@ -899,11 +927,27 @@ async function runChat(args, client) {
|
|
|
899
927
|
});
|
|
900
928
|
const wait = Number.isFinite(args.wait) ? args.wait : undefined;
|
|
901
929
|
const tail = chatTailLoop({
|
|
902
|
-
|
|
930
|
+
// `includeOwn`: your own app sends ring the doorbell too, so talking in
|
|
931
|
+
// the web while sitting here shows both halves of the conversation. The
|
|
932
|
+
// seen set already keeps the CLI's own sends from printing twice.
|
|
933
|
+
poll: (since) => client.pollEvents({
|
|
934
|
+
since,
|
|
935
|
+
wait,
|
|
936
|
+
includeOwn: true,
|
|
937
|
+
signal: inFlight.signal,
|
|
938
|
+
}),
|
|
903
939
|
fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
|
|
904
|
-
|
|
940
|
+
// With includeOwn on, a send's event can race its own HTTP response:
|
|
941
|
+
// the tail may refetch before `seen.add(messageId)` runs. Bodies in
|
|
942
|
+
// flight are suppressed for that window (the typed line on screen is
|
|
943
|
+
// the echo); the id joins `seen` when the send resolves.
|
|
944
|
+
print: (msg) => {
|
|
945
|
+
if (pendingSendBodies.has(msg.body))
|
|
946
|
+
return;
|
|
947
|
+
printAbove(renderChatMessage(msg, Date.now()));
|
|
948
|
+
},
|
|
905
949
|
warn: (text) => printAbove(`${colors.dim}${text}${colors.reset}`),
|
|
906
|
-
sleep,
|
|
950
|
+
sleep: stoppableSleep(() => stop),
|
|
907
951
|
}, {
|
|
908
952
|
roomId: room._id,
|
|
909
953
|
seen,
|
|
@@ -924,16 +968,20 @@ async function runChat(args, client) {
|
|
|
924
968
|
}
|
|
925
969
|
if (line === "/quit" || line === "/exit" || line === "/q")
|
|
926
970
|
break;
|
|
971
|
+
pendingSendBodies.add(line);
|
|
927
972
|
try {
|
|
928
973
|
// The send's own echo is the typed line still on screen; recording the
|
|
929
974
|
// id keeps the tail's refetch from printing it a second time.
|
|
930
|
-
const { messageId } = await client.sendRoomMessage(room._id, line);
|
|
975
|
+
const { messageId } = await client.sendRoomMessage(room._id, line, clientLabel);
|
|
931
976
|
seen.add(messageId);
|
|
932
977
|
}
|
|
933
978
|
catch (e) {
|
|
934
979
|
const msg = e instanceof Error ? e.message : String(e);
|
|
935
980
|
process.stderr.write(`${colors.red}error:${colors.reset} ${msg}\n`);
|
|
936
981
|
}
|
|
982
|
+
finally {
|
|
983
|
+
pendingSendBodies.delete(line);
|
|
984
|
+
}
|
|
937
985
|
if (isTty)
|
|
938
986
|
rl.prompt();
|
|
939
987
|
}
|
|
@@ -948,6 +996,165 @@ async function runChat(args, client) {
|
|
|
948
996
|
if (isTty)
|
|
949
997
|
console.log("");
|
|
950
998
|
}
|
|
999
|
+
/**
|
|
1000
|
+
* `sfora chat <room> --follow` — the tail without the prompt.
|
|
1001
|
+
*
|
|
1002
|
+
* For logs, pipes, and agents: history first (respecting `-n`), then new
|
|
1003
|
+
* messages as they arrive — no prompt, no TTY needed. The poll asks for the
|
|
1004
|
+
* caller's OWN messages too (`includeOwn`), so the same person talking from
|
|
1005
|
+
* the app appears here; nothing prints twice, the seen set is the ledger.
|
|
1006
|
+
* `--json` emits one NDJSON object per message. ^C exits cleanly.
|
|
1007
|
+
*/
|
|
1008
|
+
async function runChatFollow(args, client, room, tag, clientLabel) {
|
|
1009
|
+
const emit = (msg) => console.log(args.json ? chatMessageJson(msg) : renderChatMessage(msg, Date.now()));
|
|
1010
|
+
// History, oldest first — seeded wide enough that the doorbell's refetch
|
|
1011
|
+
// (20) never "discovers" older history, printed only as far as -n asked.
|
|
1012
|
+
const limit = Number.isFinite(args.limit) && args.limit > 0
|
|
1013
|
+
? Math.min(args.limit, 100)
|
|
1014
|
+
: 30;
|
|
1015
|
+
const history = await client.listRoomMessages(room._id, Math.max(limit, 20));
|
|
1016
|
+
const seeded = history.page.slice().reverse();
|
|
1017
|
+
const seen = new Set(seeded.map((m) => m._id));
|
|
1018
|
+
for (const msg of seeded.slice(-limit))
|
|
1019
|
+
emit(msg);
|
|
1020
|
+
process.stderr.write(`${colors.dim}following ${tag} — ^C to stop${colors.reset}\n`);
|
|
1021
|
+
// Following is being in the room: beat now, then every ~45s, same as the
|
|
1022
|
+
// interactive chat. `unref` so the timer never holds the process open.
|
|
1023
|
+
const beat = () => client.heartbeat({ roomId: room._id, client: clientLabel }).catch(() => { });
|
|
1024
|
+
void beat();
|
|
1025
|
+
const pulse = setInterval(() => void beat(), 45_000);
|
|
1026
|
+
pulse.unref?.();
|
|
1027
|
+
// ^C has to reach the socket — the long-poll hangs for tens of seconds.
|
|
1028
|
+
// Second ^C stands down and lets the default behaviour end things.
|
|
1029
|
+
let stop = false;
|
|
1030
|
+
const inFlight = new AbortController();
|
|
1031
|
+
let signalled = false;
|
|
1032
|
+
const onSignal = (signal) => {
|
|
1033
|
+
stop = true;
|
|
1034
|
+
inFlight.abort();
|
|
1035
|
+
if (signalled) {
|
|
1036
|
+
process.off("SIGINT", onSignal);
|
|
1037
|
+
process.off("SIGTERM", onSignal);
|
|
1038
|
+
process.kill(process.pid, signal);
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
signalled = true;
|
|
1042
|
+
};
|
|
1043
|
+
process.on("SIGINT", onSignal);
|
|
1044
|
+
process.on("SIGTERM", onSignal);
|
|
1045
|
+
const wait = Number.isFinite(args.wait) ? args.wait : undefined;
|
|
1046
|
+
try {
|
|
1047
|
+
await chatTailLoop({
|
|
1048
|
+
poll: (since) => client.pollEvents({
|
|
1049
|
+
since,
|
|
1050
|
+
wait,
|
|
1051
|
+
includeOwn: true,
|
|
1052
|
+
signal: inFlight.signal,
|
|
1053
|
+
}),
|
|
1054
|
+
fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
|
|
1055
|
+
print: emit,
|
|
1056
|
+
warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
|
|
1057
|
+
sleep: stoppableSleep(() => stop),
|
|
1058
|
+
}, {
|
|
1059
|
+
roomId: room._id,
|
|
1060
|
+
seen,
|
|
1061
|
+
// Cursor on SERVER time — see the interactive tail.
|
|
1062
|
+
since: history.page[0]?._creationTime ?? Date.now(),
|
|
1063
|
+
stopped: () => stop,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
finally {
|
|
1067
|
+
clearInterval(pulse);
|
|
1068
|
+
process.off("SIGINT", onSignal);
|
|
1069
|
+
process.off("SIGTERM", onSignal);
|
|
1070
|
+
}
|
|
1071
|
+
// A parting beat, best-effort — awaited so exit doesn't race it.
|
|
1072
|
+
await beat();
|
|
1073
|
+
}
|
|
1074
|
+
/**
|
|
1075
|
+
* `sfora chat <room> -m "…" --await-reply` — send, then hold the line.
|
|
1076
|
+
*
|
|
1077
|
+
* Blocks until the NEXT message that is not the send itself lands in the
|
|
1078
|
+
* room, prints it, and exits 0 — the reply is picked by message id, not
|
|
1079
|
+
* author, so the same human answering from the app counts. `--timeout <secs>`
|
|
1080
|
+
* gives up with exit code 2. Everything already said before the send is
|
|
1081
|
+
* seeded as seen, so an old message can never pose as the answer.
|
|
1082
|
+
*/
|
|
1083
|
+
async function runSendAwaitReply(args, client, room, tag, text, clientLabel) {
|
|
1084
|
+
// Seed BEFORE the send: the history is not the reply, and the events cursor
|
|
1085
|
+
// is server time, so a fast local clock opens no blind window.
|
|
1086
|
+
const history = await client.listRoomMessages(room._id, 50);
|
|
1087
|
+
const seen = new Set(history.page.map((m) => m._id));
|
|
1088
|
+
const since = history.page[0]?._creationTime ?? Date.now();
|
|
1089
|
+
// A mistyped --timeout must fail loudly, not silently wait forever — the
|
|
1090
|
+
// exact opposite of what the flag asked for (reviewer catch).
|
|
1091
|
+
if (args.timeout !== undefined && (!Number.isFinite(args.timeout) || args.timeout <= 0)) {
|
|
1092
|
+
throw new Error("--timeout needs a positive number of seconds");
|
|
1093
|
+
}
|
|
1094
|
+
const { messageId } = await client.sendRoomMessage(room._id, text, clientLabel);
|
|
1095
|
+
seen.add(messageId);
|
|
1096
|
+
const deadlineMs = args.timeout !== undefined ? Date.now() + args.timeout * 1000 : undefined;
|
|
1097
|
+
if (args.json) {
|
|
1098
|
+
// NDJSON-shaped: this line says it landed; the reply is the next line.
|
|
1099
|
+
console.log(JSON.stringify({ messageId, roomId: room._id }));
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
console.log(`${colors.green}✓${colors.reset} Sent to ${tag}`);
|
|
1103
|
+
process.stderr.write(`${colors.dim}waiting for a reply${deadlineMs ? ` (up to ${args.timeout}s)` : ""} — ^C to stop${colors.reset}\n`);
|
|
1104
|
+
}
|
|
1105
|
+
// Same signal shape as `ask --wait`: ^C reaches the socket, second ^C
|
|
1106
|
+
// stands down and lets the default behaviour end things.
|
|
1107
|
+
let stop = false;
|
|
1108
|
+
const inFlight = new AbortController();
|
|
1109
|
+
let signalled = false;
|
|
1110
|
+
const onSignal = (signal) => {
|
|
1111
|
+
stop = true;
|
|
1112
|
+
inFlight.abort();
|
|
1113
|
+
if (signalled) {
|
|
1114
|
+
process.off("SIGINT", onSignal);
|
|
1115
|
+
process.off("SIGTERM", onSignal);
|
|
1116
|
+
process.kill(process.pid, signal);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
signalled = true;
|
|
1120
|
+
};
|
|
1121
|
+
process.on("SIGINT", onSignal);
|
|
1122
|
+
process.on("SIGTERM", onSignal);
|
|
1123
|
+
try {
|
|
1124
|
+
const result = await awaitReplyLoop({
|
|
1125
|
+
// `includeOwn`: the same member answering from the app must ring the
|
|
1126
|
+
// doorbell — the reply filter is by message id, not author. With a
|
|
1127
|
+
// deadline, each poll's server-side budget is capped to what is left.
|
|
1128
|
+
poll: (cursor) => client.pollEvents({
|
|
1129
|
+
since: cursor,
|
|
1130
|
+
wait: deadlineMs
|
|
1131
|
+
? Math.max(1, Math.ceil((deadlineMs - Date.now()) / 1000))
|
|
1132
|
+
: undefined,
|
|
1133
|
+
includeOwn: true,
|
|
1134
|
+
signal: inFlight.signal,
|
|
1135
|
+
}),
|
|
1136
|
+
fetchRecent: async () => (await client.listRoomMessages(room._id, 20)).page,
|
|
1137
|
+
warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
|
|
1138
|
+
sleep: stoppableSleep(() => stop),
|
|
1139
|
+
}, { roomId: room._id, seen, since, deadlineMs, stopped: () => stop });
|
|
1140
|
+
if (result.outcome === "reply") {
|
|
1141
|
+
console.log(args.json
|
|
1142
|
+
? chatMessageJson(result.message)
|
|
1143
|
+
: renderChatMessage(result.message, Date.now()));
|
|
1144
|
+
}
|
|
1145
|
+
else if (result.outcome === "timeout") {
|
|
1146
|
+
process.stderr.write(`${colors.dim}no reply within ${args.timeout}s${colors.reset}\n`);
|
|
1147
|
+
// 2, not 1: "nobody answered" is its own branch for a script — distinct
|
|
1148
|
+
// from "the send failed", which throws and exits 1.
|
|
1149
|
+
process.exitCode = 2;
|
|
1150
|
+
}
|
|
1151
|
+
// stopped (^C): say nothing — the message is sent and stands.
|
|
1152
|
+
}
|
|
1153
|
+
finally {
|
|
1154
|
+
process.off("SIGINT", onSignal);
|
|
1155
|
+
process.off("SIGTERM", onSignal);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
951
1158
|
/**
|
|
952
1159
|
* `sfora ask` — how an agent asks a human, wired to the real process.
|
|
953
1160
|
*
|
|
@@ -1063,7 +1270,7 @@ async function runAsk(args, client) {
|
|
|
1063
1270
|
signal: inFlight.signal,
|
|
1064
1271
|
}),
|
|
1065
1272
|
warn: (t) => process.stderr.write(`${colors.dim}${t}${colors.reset}\n`),
|
|
1066
|
-
sleep,
|
|
1273
|
+
sleep: stoppableSleep(() => stop),
|
|
1067
1274
|
}, { askId: created.askId, since, deadlineMs, stopped: () => stop });
|
|
1068
1275
|
if (result.outcome === "answered") {
|
|
1069
1276
|
const answer = result.chosenOption ?? result.resolution;
|
|
@@ -1304,6 +1511,12 @@ async function main() {
|
|
|
1304
1511
|
const cfg = await readConfig();
|
|
1305
1512
|
const settings = resolveSettings({ url: args.url, apiKey: args.key, org: args.org, bot: args.bot }, cfg);
|
|
1306
1513
|
const { url: baseUrl, apiKey } = settings;
|
|
1514
|
+
// Once per invocation: what kind of client is on the line. The api client
|
|
1515
|
+
// sends it on every request (`X-Sfora-Client`), so every creation door
|
|
1516
|
+
// stamps its work — a post made from Claude Code says so on the feed, the
|
|
1517
|
+
// same way a chat message does. Sent always, "cli" included: a plain
|
|
1518
|
+
// terminal is honest attribution too.
|
|
1519
|
+
const clientLabel = detectClient(process.env, args.client);
|
|
1307
1520
|
if (args.command === "mcp-config") {
|
|
1308
1521
|
printMcpConfig(settings);
|
|
1309
1522
|
return;
|
|
@@ -1344,6 +1557,7 @@ async function main() {
|
|
|
1344
1557
|
apiKey: apiKey ?? "",
|
|
1345
1558
|
org: settings.org ?? "",
|
|
1346
1559
|
localRoot,
|
|
1560
|
+
clientLabel,
|
|
1347
1561
|
});
|
|
1348
1562
|
return;
|
|
1349
1563
|
}
|
|
@@ -1372,6 +1586,7 @@ async function main() {
|
|
|
1372
1586
|
apiKey,
|
|
1373
1587
|
org: settings.org ?? "",
|
|
1374
1588
|
actAs: args.as,
|
|
1589
|
+
clientLabel,
|
|
1375
1590
|
presence: runPresence,
|
|
1376
1591
|
});
|
|
1377
1592
|
try {
|
|
@@ -1392,7 +1607,7 @@ async function main() {
|
|
|
1392
1607
|
}
|
|
1393
1608
|
// MCP mode: stdout is the protocol channel — never write logs there.
|
|
1394
1609
|
if (args.mcp) {
|
|
1395
|
-
await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "" });
|
|
1610
|
+
await runMcpServer({ baseUrl, apiKey, org: settings.org ?? "", clientLabel });
|
|
1396
1611
|
return;
|
|
1397
1612
|
}
|
|
1398
1613
|
if (!settings.org) {
|
|
@@ -1406,6 +1621,7 @@ async function main() {
|
|
|
1406
1621
|
apiKey,
|
|
1407
1622
|
org,
|
|
1408
1623
|
cwd: args.cwd,
|
|
1624
|
+
clientLabel,
|
|
1409
1625
|
presence: runPresence,
|
|
1410
1626
|
});
|
|
1411
1627
|
// Pre-flight: confirm auth + connectivity and greet with the resolved identity.
|
package/dist/index.d.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface CreateSforaShellOptions {
|
|
|
25
25
|
cwd?: string;
|
|
26
26
|
/** Act/post as an owned agent, using this key (sent as `X-Sfora-Act-As`). */
|
|
27
27
|
actAs?: string;
|
|
28
|
+
/**
|
|
29
|
+
* What kind of client is on the line ("claude-code", "cli"). Sent as
|
|
30
|
+
* `X-Sfora-Client` on every request so writes are stamped with the terminal
|
|
31
|
+
* that made them — the CLI passes `detectClient(process.env, --client)`.
|
|
32
|
+
*/
|
|
33
|
+
clientLabel?: string;
|
|
28
34
|
/**
|
|
29
35
|
* The run's "you are visible" latch. Pass one when something OUTSIDE this
|
|
30
36
|
* shell can print the note too — the CLI does, after any line that wrote —
|
|
@@ -69,5 +75,5 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, t
|
|
|
69
75
|
export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
|
|
70
76
|
export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type WatchOptions, type WatchTarget, } from "./watch.js";
|
|
71
77
|
export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
|
|
72
|
-
export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
|
|
78
|
+
export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, type ChatTailDeps, type ChatTailOptions, type AwaitReplyDeps, type AwaitReplyOptions, type AwaitReplyResult, } from "./chat.js";
|
|
73
79
|
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
|
@@ -17,6 +17,7 @@ export function createSforaShell(options) {
|
|
|
17
17
|
baseUrl: options.baseUrl,
|
|
18
18
|
apiKey: options.apiKey,
|
|
19
19
|
actAs: options.actAs,
|
|
20
|
+
clientLabel: options.clientLabel,
|
|
20
21
|
});
|
|
21
22
|
const presence = options.presence ?? presenceNotice();
|
|
22
23
|
const fs = new SforaFs(client);
|
|
@@ -57,5 +58,5 @@ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, }
|
|
|
57
58
|
export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
|
|
58
59
|
export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
|
|
59
60
|
export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
|
|
60
|
-
export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug } from "./chat.js";
|
|
61
|
+
export { KNOWN_CLIENTS, detectClient, sanitizeClientSlug, chatMessageJson, chatTailLoop, awaitReplyLoop, } from "./chat.js";
|
|
61
62
|
export { validateAskOptions, reshapeCandidatesError, askWaitLoop, ASK_OPTIONS_MIN, ASK_OPTIONS_MAX, ASK_OPTION_MAX_LENGTH, } from "./ask.js";
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -12,5 +12,7 @@ export interface RunMcpServerOptions {
|
|
|
12
12
|
org: string;
|
|
13
13
|
/** Absolute path of a local `.sfora/` workspace — serves it instead of the cloud. */
|
|
14
14
|
localRoot?: string;
|
|
15
|
+
/** Terminal client slug for write attribution (`X-Sfora-Client`). */
|
|
16
|
+
clientLabel?: string;
|
|
15
17
|
}
|
|
16
18
|
export declare function runMcpServer(options: RunMcpServerOptions): Promise<void>;
|
package/dist/mcp-server.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sfora-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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": [
|