@shardflux/cli 0.24.0 → 0.25.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +148 -0
  2. package/README.md +263 -1
  3. package/dist/account-args.js +5 -0
  4. package/dist/account-pool.js +5 -1
  5. package/dist/acp-journal.js +23 -1
  6. package/dist/acp-permission-elsewhere.d.ts +38 -0
  7. package/dist/acp-permission-elsewhere.js +85 -0
  8. package/dist/acp-rpc.d.ts +2 -1
  9. package/dist/acp-rpc.js +11 -2
  10. package/dist/acp.js +95 -22
  11. package/dist/agent-credentials.d.ts +3 -0
  12. package/dist/agent-credentials.js +7 -0
  13. package/dist/args.d.ts +1 -1
  14. package/dist/args.js +172 -9
  15. package/dist/board-args.d.ts +6 -0
  16. package/dist/board-args.js +172 -0
  17. package/dist/board-commands.d.ts +62 -0
  18. package/dist/board-commands.js +412 -0
  19. package/dist/codex-cloud.d.ts +30 -3
  20. package/dist/codex-cloud.js +86 -12
  21. package/dist/conversation-commands.d.ts +30 -0
  22. package/dist/conversation-commands.js +328 -9
  23. package/dist/conversation-transcript.d.ts +44 -0
  24. package/dist/conversation-transcript.js +206 -0
  25. package/dist/conversations.d.ts +7 -3
  26. package/dist/conversations.js +20 -10
  27. package/dist/device-approvals.js +4 -3
  28. package/dist/device-bridge.d.ts +42 -1
  29. package/dist/device-bridge.js +223 -12
  30. package/dist/device-folders.d.ts +66 -0
  31. package/dist/device-folders.js +236 -0
  32. package/dist/device-helper.d.ts +6 -0
  33. package/dist/device-helper.js +98 -14
  34. package/dist/drive-commands.d.ts +25 -0
  35. package/dist/drive-commands.js +201 -0
  36. package/dist/errors.js +2 -1
  37. package/dist/format.js +6 -2
  38. package/dist/handlers.js +7 -1
  39. package/dist/land.d.ts +116 -0
  40. package/dist/land.js +238 -0
  41. package/dist/main.js +10 -1
  42. package/dist/move-files.d.ts +39 -0
  43. package/dist/move-files.js +167 -0
  44. package/dist/move.js +12 -11
  45. package/dist/onboarding.d.ts +10 -6
  46. package/dist/onboarding.js +24 -6
  47. package/dist/project-git.d.ts +7 -1
  48. package/dist/project-git.js +16 -3
  49. package/dist/project-preparation.d.ts +7 -0
  50. package/dist/project-preparation.js +14 -0
  51. package/dist/project-template-setup.js +32 -11
  52. package/dist/router-commands.d.ts +33 -0
  53. package/dist/router-commands.js +179 -0
  54. package/dist/task-land-hub.py +150 -0
  55. package/dist/transcribe-commands.d.ts +7 -0
  56. package/dist/transcribe-commands.js +80 -0
  57. package/dist/worktree-twin-engine.d.ts +8 -0
  58. package/dist/worktree-twin-engine.js +28 -22
  59. package/package.json +2 -2
@@ -0,0 +1,85 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { ShardfluxApiError } from '@shardflux/sdk';
3
+ import { objectValue } from "./acp-rpc.js";
4
+ /** How often the watch reads the API while the editor's permission prompt is open (it never touches the VM). */
5
+ export const ELSEWHERE_POLL_MS = 1500;
6
+ /**
7
+ * The guest reports a request as it journals it, just before the editor's prompt opens: one reported up to this long
8
+ * before (clock difference with the API included) is this prompt's; an older one counts only while it still waits (an
9
+ * editor reconnecting to a request that is still open).
10
+ */
11
+ const REPORTED_BEFORE_MS = 10_000;
12
+ /** The tool and command, path or URL the guest records for an ACP permission request (services/guest-agent ACPPermissionAsk). */
13
+ export function requestIdentity(params) {
14
+ const call = objectValue(params.toolCall), raw = objectValue(call.rawInput), str = (v) => typeof v === 'string' ? v : '';
15
+ const location = Array.isArray(call.locations) ? str(objectValue(call.locations[0]).path) : '';
16
+ const tool = str(call.name) || str(objectValue(objectValue(call._meta).claudeCode).toolName);
17
+ return { tool: tool || null, detail: str(raw.command) || location || str(raw.file_path) || str(raw.url) };
18
+ }
19
+ /**
20
+ * While the editor shows the agent's permission prompt, the journal relay is paused, so nothing else of the turn reaches
21
+ * the editor. The same request is listed through the API, where a person can answer it too (the Shard client, the CLI,
22
+ * MCP). This resolves with the request once it leaves `pending` that way (answered through the API, or closed because
23
+ * the agent no longer waits), so the relay can withdraw the editor's prompt and go on with the turn. The request is the
24
+ * newest one of the conversation with the prompt's tool and command, path or URL that was reported as the prompt opened
25
+ * or still waits. It resolves null on an API without permission requests (404, 503) or when `signal` ends the prompt;
26
+ * other errors are retried.
27
+ */
28
+ export async function answeredElsewhere(client, conversationId, params, openedAt, signal, pollMs = ELSEWHERE_POLL_MS) {
29
+ const want = requestIdentity(params);
30
+ let request;
31
+ for (;;) {
32
+ try {
33
+ await delay(pollMs, undefined, { signal });
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ try {
39
+ if (!request) {
40
+ // Every state, every page: an answer through the API can land before the first read.
41
+ let mine;
42
+ for (let cursor, pages = 0; pages < 20; pages++) {
43
+ const page = await client.conversations.permissions(conversationId, { limit: 200, ...(cursor ? { cursor } : {}) });
44
+ for (const p of page.data) {
45
+ if (p.detail === want.detail && (p.tool ?? null) === want.tool && (p.state === 'pending' || Date.parse(p.created_at) >= openedAt - REPORTED_BEFORE_MS))
46
+ mine = p;
47
+ }
48
+ if (!page.nextCursor)
49
+ break;
50
+ cursor = page.nextCursor;
51
+ }
52
+ if (!mine)
53
+ continue;
54
+ if (mine.state !== 'pending')
55
+ return mine;
56
+ request = mine.id;
57
+ continue;
58
+ }
59
+ const p = await client.conversations.permission(conversationId, request);
60
+ if (p.state !== 'pending')
61
+ return p;
62
+ }
63
+ catch (error) {
64
+ if (signal.aborted)
65
+ return null;
66
+ if (error instanceof ShardfluxApiError && (error.status === 404 || error.status === 503))
67
+ return null;
68
+ }
69
+ }
70
+ }
71
+ /**
72
+ * The editor-side answer matching a request answered elsewhere: the option of its decision's kind (as the guest picks
73
+ * it), or a cancelled outcome. The agent already has the first answer; this one only completes the relayed request.
74
+ */
75
+ export function outcomeFor(p, params) {
76
+ const options = (Array.isArray(params.options) ? params.options : []).map(objectValue);
77
+ const wanted = p.decision === 'allow_once' ? ['allow_once', 'allow_always'] : p.decision === 'allow_always' ? ['allow_always', 'allow_once']
78
+ : p.decision === 'deny' ? ['reject_once', 'reject_always'] : [];
79
+ for (const kind of wanted) {
80
+ const option = options.find(o => o.kind === kind);
81
+ if (typeof option?.optionId === 'string')
82
+ return { outcome: { outcome: 'selected', optionId: option.optionId } };
83
+ }
84
+ return { outcome: { outcome: 'cancelled' } };
85
+ }
package/dist/acp-rpc.d.ts CHANGED
@@ -34,7 +34,8 @@ export declare class AcpRpc {
34
34
  get busy(): boolean;
35
35
  get closed(): boolean;
36
36
  feed(bytes: Uint8Array): void;
37
- request(method: string, params: ObjectValue, timeoutMs?: number, signal?: AbortSignal): Promise<unknown>;
37
+ /** `cancelOnAbort`: an abort also tells the peer the request is withdrawn (`$/cancel_request`), so an editor drops its prompt. */
38
+ request(method: string, params: ObjectValue, timeoutMs?: number, signal?: AbortSignal, cancelOnAbort?: boolean): Promise<unknown>;
38
39
  notify(method: string, params: ObjectValue): Promise<void>;
39
40
  close(error?: Error): void;
40
41
  drain(): Promise<void>;
package/dist/acp-rpc.js CHANGED
@@ -86,7 +86,8 @@ export class AcpRpc {
86
86
  pending.resolve(message.result);
87
87
  this.onIdle();
88
88
  }
89
- request(method, params, timeoutMs = 120_000, signal) {
89
+ /** `cancelOnAbort`: an abort also tells the peer the request is withdrawn (`$/cancel_request`), so an editor drops its prompt. */
90
+ request(method, params, timeoutMs = 120_000, signal, cancelOnAbort = false) {
90
91
  if (this.#closed)
91
92
  return Promise.reject(this.#closed);
92
93
  if (signal?.aborted)
@@ -95,7 +96,15 @@ export class AcpRpc {
95
96
  return new Promise((resolve, reject) => {
96
97
  const cleanup = () => signal?.removeEventListener('abort', abort);
97
98
  const timer = timeoutMs > 0 ? setTimeout(() => { this.pending.delete(id); cleanup(); reject(new RpcError(-32000, 'Request timed out', { code: 'timeout', fix: 'Try the message again.' })); this.onIdle(); }, timeoutMs) : undefined;
98
- const abort = () => { this.pending.delete(id); clearTimeout(timer); cleanup(); reject(signal?.reason instanceof Error ? signal.reason : new Error('Request interrupted')); this.onIdle(); };
99
+ const abort = () => {
100
+ this.pending.delete(id);
101
+ clearTimeout(timer);
102
+ cleanup();
103
+ if (cancelOnAbort && !this.#closed)
104
+ void this.#write({ jsonrpc: '2.0', method: '$/cancel_request', params: { requestId: id } }).catch(() => undefined);
105
+ reject(signal?.reason instanceof Error ? signal.reason : new Error('Request interrupted'));
106
+ this.onIdle();
107
+ };
99
108
  this.pending.set(id, { resolve, reject, timer, cleanup });
100
109
  signal?.addEventListener('abort', abort, { once: true });
101
110
  void this.#write({ jsonrpc: '2.0', id, method, params }).catch((error) => { this.pending.delete(id); clearTimeout(timer); cleanup(); reject(error instanceof Error ? error : new Error(String(error))); this.onIdle(); });
package/dist/acp.js CHANGED
@@ -6,6 +6,8 @@ import { AcpRpc, RpcError, objectValue } from "./acp-rpc.js";
6
6
  import { ACP_PROBE_CATALOG, nativeCurrentModel, nativeModelOptions, withoutNativeModel } from "./acp-catalog.js";
7
7
  import { ACP_LIFECYCLE, AcpModelCache, acpScope } from "./acp-models.js";
8
8
  import { AcpJournal } from "./acp-journal.js";
9
+ import { answeredElsewhere, outcomeFor } from "./acp-permission-elsewhere.js";
10
+ import { conversationClient } from "./conversation-commands.js";
9
11
  import { getConversation, conversationWorkspace, preparedInFlight, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
10
12
  import { seedEvent } from "./seed-phases.js";
11
13
  import { projectIdentity } from "./project-identity.js";
@@ -45,6 +47,35 @@ export function acpModelOptions(catalog) {
45
47
  });
46
48
  }
47
49
  const SIGN_IN = 'Sign in to Shardflux from your editor (or run shard acp --login).';
50
+ const CONVERSATION_ID = /^conv_[a-z2-7]{26}$/;
51
+ /** A remote session has no local folder to keep in step: its files stay in the conversation's computer. */
52
+ const NO_FILES = { root: '', conflicts: () => [], reconcile: () => Promise.resolve(), settled: () => true };
53
+ /** The last sequence number in a conversation's ACP journal: a session loaded by id follows from there (its history comes
54
+ * from the agent's own session/load replay, not from the journal). */
55
+ async function journalEnd(cell, conversationId) {
56
+ const path = `/home/user/.local/share/shardflux/acp/${conversationId}/journal.ndjson`;
57
+ let size;
58
+ try {
59
+ size = (await cell.files.stat(path)).size;
60
+ }
61
+ catch (error) {
62
+ if (error.status === 404)
63
+ return 0;
64
+ throw error;
65
+ }
66
+ for (let want = 64 << 10;; want *= 4) {
67
+ const start = Math.max(0, size - want), text = Buffer.from(await cell.files.read(path, { offset: start, length: size - start })).toString('utf8');
68
+ const lines = text.split('\n').filter(Boolean), last = start > 0 && lines.length < 2 ? undefined : lines.at(-1);
69
+ if (last !== undefined) {
70
+ const seq = JSON.parse(last).seq;
71
+ if (Number.isSafeInteger(seq))
72
+ return seq;
73
+ throw new Error('Invalid ACP journal entry');
74
+ }
75
+ if (start === 0)
76
+ return 0;
77
+ }
78
+ }
48
79
  function supervisorOwner(workspaceId, conversationId, app, text) {
49
80
  const status = JSON.parse(text);
50
81
  if (status.conversation_id !== conversationId || status.harness !== app || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !status.start)
@@ -125,7 +156,7 @@ export class ShardfluxAcp {
125
156
  if (!this.#initialized)
126
157
  throw new RpcError(-32600, 'Initialize first');
127
158
  if (method === 'session/list') {
128
- const sessions = Object.values(this.#records()).filter(r => r.apiUrl === this.#ctx.apiUrl && (!params.cwd || r.folder === params.cwd));
159
+ const sessions = Object.values(this.#records()).filter(r => r.apiUrl === this.#ctx.apiUrl && !r.remote && (!params.cwd || r.folder === params.cwd));
129
160
  return { sessions: sessions.map(r => ({ sessionId: r.id, cwd: r.folder, updatedAt: r.updatedAt })) };
130
161
  }
131
162
  if (method === 'session/new') {
@@ -163,12 +194,14 @@ export class ShardfluxAcp {
163
194
  throw new RpcError(-32601, 'The provider probe has no conversation.');
164
195
  if (typeof params.sessionId !== 'string')
165
196
  throw new RpcError(-32602, 'A conversation is required');
166
- const session = this.#session(params.sessionId, params.cwd);
197
+ const session = this.#session(params.sessionId, params.cwd, method);
167
198
  const { record } = session;
168
199
  if (method === 'session/close') {
169
200
  await this.#close(session);
170
201
  return {};
171
202
  }
203
+ if (record.remote && !record.model)
204
+ await this.#remoteHarness(record);
172
205
  if (method === 'session/set_model' || (method === 'session/set_config_option' && params.configId === 'model')) {
173
206
  const model = String(method === 'session/set_model' ? params.modelId : params.value), selected = parseAcpModel(model);
174
207
  if (!record.catalog && record.conversationId)
@@ -272,11 +305,17 @@ export class ShardfluxAcp {
272
305
  record.scope = acpScope(ctx.apiUrl, view);
273
306
  this.#cache.remember(ctx, record.folder, record.scope);
274
307
  if (previous && JSON.stringify(previous) !== JSON.stringify(record.scope)) {
275
- record.catalog = this.#cache.read(record.scope);
276
- const app = record.catalog.claude ? 'claude' : 'codex';
277
- record.model = record.catalog[app] ? acpModel(app, nativeCurrentModel(record.catalog[app])) : '';
278
- if (session.announced)
279
- void this.#modelUpdate(session).catch(() => { });
308
+ const cached = this.#cache.read(record.scope);
309
+ // A newer template of the same account has no cached models yet: the models this session offered stay
310
+ // until its live discovery replaces them, so an editor's first choice is not refused (0.25.0+). Before, the
311
+ // list went empty for those seconds and set_model failed with "no longer available".
312
+ if (Object.keys(cached).length || previous.organizationId !== record.scope.organizationId) {
313
+ record.catalog = cached;
314
+ const app = record.catalog.claude ? 'claude' : 'codex';
315
+ record.model = record.catalog[app] ? acpModel(app, nativeCurrentModel(record.catalog[app])) : '';
316
+ if (session.announced)
317
+ void this.#modelUpdate(session).catch(() => { });
318
+ }
280
319
  }
281
320
  this.#save(record);
282
321
  },
@@ -354,9 +393,15 @@ export class ShardfluxAcp {
354
393
  this.#save(record);
355
394
  await this.#modelUpdate(session);
356
395
  }
357
- #session(id, cwd) {
358
- const record = this.#records()[id];
359
- if (!record || record.apiUrl !== this.#ctx.apiUrl || (cwd !== undefined && (typeof cwd !== 'string' || projectIdentity(cwd).folder !== record.folder)))
396
+ #session(id, cwd, method) {
397
+ let record = this.#records()[id];
398
+ // Any client can open a conversation it knows by id (session/load or session/resume with a conv_ id): it relays to
399
+ // the conversation's own ACP session in its computer, with no folder of this computer to sync.
400
+ if (!record && CONVERSATION_ID.test(id) && (method === 'session/load' || method === 'session/resume')) {
401
+ record = { id, apiUrl: this.#ctx.apiUrl, folder: typeof cwd === 'string' ? cwd : '', conversationId: id, model: '', from_seq: 0, prompted: true, remote: true, updatedAt: new Date().toISOString() };
402
+ this.#save(record);
403
+ }
404
+ if (!record || record.apiUrl !== this.#ctx.apiUrl || (cwd !== undefined && (typeof cwd !== 'string' || (record.remote ? cwd !== record.folder : projectIdentity(cwd).folder !== record.folder))))
360
405
  throw new RpcError(-32000, 'Conversation not found.');
361
406
  let session = this.#sessions.get(id);
362
407
  if (!session) {
@@ -365,6 +410,17 @@ export class ShardfluxAcp {
365
410
  }
366
411
  return session;
367
412
  }
413
+ /** A conversation loaded by id: its harness from the API. Only an ACP conversation has a session to relay to. */
414
+ async #remoteHarness(record) {
415
+ const c = await getConversation(this.#ctx, record.conversationId);
416
+ if (c.route !== 'acp')
417
+ throw new RpcError(-32602, `This conversation runs in ${c.route === 'terminal' ? 'a terminal' : c.route}; open it there.`);
418
+ if (!c.harness_session_id)
419
+ throw new RpcError(-32602, 'This conversation has not started its session yet. Try again in a moment.');
420
+ record.app = c.harness;
421
+ record.model = acpModel(c.harness, DEFAULT_MODEL);
422
+ this.#save(record);
423
+ }
368
424
  /** True when this call started the connection (and so reconciled the project files just now). */
369
425
  async #connect(session) {
370
426
  await session.prepared;
@@ -428,20 +484,21 @@ export class ShardfluxAcp {
428
484
  const current = await getConversation(ctx, record.conversationId);
429
485
  session.conversation = await resumeConversation(ctx, current);
430
486
  session.ws = await conversationWorkspace(ctx, session.conversation);
431
- rememberConversation(ctx, session.conversation, record.folder);
487
+ if (!record.remote)
488
+ rememberConversation(ctx, session.conversation, record.folder);
432
489
  mark('conversation');
433
490
  }
434
491
  await signIn;
435
492
  mark('signed_in');
436
493
  let c = session.conversation;
437
494
  const cell = session.ws.cell();
438
- const menubar = ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
495
+ const menubar = record.remote ? Promise.resolve() : ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
439
496
  await restoreConversationBinding(ctx, c, session.ws);
440
497
  mark('binding');
441
498
  // The project files are reconciled, device approvals served and the supervisor launched side by side; the native
442
499
  // session starts once the supervisor is up, and the connection is done (the prompt goes) once the files are
443
500
  // reconciled too (0.21.1+).
444
- const files = (async () => {
501
+ const files = record.remote ? Promise.resolve(NO_FILES) : (async () => {
445
502
  const boundary = await projectSyncBoundary({ ...ctx, signal: AbortSignal.any([ctx.signal, session.stop.signal]) }, session.ws, projectIdentity(record.folder), {
446
503
  root: c.worktree_path, agent: record.id.replace(/^sf-/, 'acp-'), seed: false,
447
504
  stateKey: `acp:${ctx.apiUrl}:${record.id}:${record.folder}`, ...(session.hashes ? { cache: session.hashes } : {}),
@@ -464,7 +521,7 @@ export class ShardfluxAcp {
464
521
  session.approvals ??= await serveAcpDeviceApprovals(ctx.store.dir, c.id, async (approval) => {
465
522
  const result = objectValue(await this.#client.request('session/request_permission', { sessionId: record.id,
466
523
  toolCall: { toolCallId: approval.request_id, title: approval.summary, kind: 'execute', status: 'pending' },
467
- options: [{ optionId: 'once', name: 'Allow once', kind: 'allow_once' }, ...(!approval.outside ? [{ optionId: 'always', name: 'Always for this project', kind: 'allow_always' }] : []), { optionId: 'deny', name: 'Deny', kind: 'reject_once' }] }, 0, AbortSignal.any([ctx.signal, session.stop.signal])));
524
+ options: [{ optionId: 'once', name: 'Allow once', kind: 'allow_once' }, ...(approval.scope === 'folder' ? [{ optionId: 'always', name: 'Always for this folder', kind: 'allow_always' }] : !approval.outside ? [{ optionId: 'always', name: 'Always for this project', kind: 'allow_always' }] : []), { optionId: 'deny', name: 'Deny', kind: 'reject_once' }] }, 0, AbortSignal.any([ctx.signal, session.stop.signal])));
468
525
  const outcome = objectValue(result.outcome);
469
526
  return outcome.outcome === 'selected' && (outcome.optionId === 'once' || outcome.optionId === 'always') ? outcome.optionId : 'deny';
470
527
  });
@@ -480,7 +537,8 @@ export class ShardfluxAcp {
480
537
  if (c.harness !== app)
481
538
  throw new Error('The guest did not claim this conversation harness.');
482
539
  session.conversation = c;
483
- rememberConversation(ctx, c, record.folder);
540
+ if (!record.remote)
541
+ rememberConversation(ctx, c, record.folder);
484
542
  const owner = supervisorOwner(session.ws.id, c.id, app, statusText);
485
543
  if (owner === null)
486
544
  throw new Error('ACP supervisor identity does not match this conversation.');
@@ -488,7 +546,8 @@ export class ShardfluxAcp {
488
546
  // A cold restore can replace the journal owner without changing the VM id.
489
547
  // Its sequence starts at zero; an old cursor would drop initialize/load replies.
490
548
  if (record.workspaceId !== session.ws.id || record.owner !== owner) {
491
- record.from_seq = 0;
549
+ // A conversation loaded by id follows its journal from now: the agent's session/load replays its history.
550
+ record.from_seq = record.remote ? await journalEnd(cell, c.id) : 0;
492
551
  delete record.prompt_id;
493
552
  }
494
553
  record.workspaceId = session.ws.id;
@@ -508,17 +567,28 @@ export class ShardfluxAcp {
508
567
  mapped.update = { ...update, ...this.#configuration(record.model, native, record.catalog) };
509
568
  }
510
569
  }
511
- if (request) {
512
- if (method === 'session/request_permission')
513
- transport.pause();
570
+ if (request && method === 'session/request_permission') {
571
+ // The journal relay pauses while the editor shows the prompt (a parked VM stays parked). The person can answer
572
+ // the same request through the API meanwhile (0.25.0+): then the editor's prompt is withdrawn and the turn goes on.
573
+ transport.pause();
574
+ const stop = new AbortController(), signal = AbortSignal.any([ctx.signal, session.stop.signal]);
514
575
  try {
515
- return acpReferences(await this.#client.request(method, mapped, 0, AbortSignal.any([ctx.signal, session.stop.signal])), record, c.worktree_path, true);
576
+ const asked = this.#client.request(method, mapped, 0, AbortSignal.any([signal, stop.signal]), true).then(result => ({ result }));
577
+ const elsewhere = answeredElsewhere(conversationClient(ctx), c.id, params, Date.now(), AbortSignal.any([signal, stop.signal]))
578
+ .then(p => p ? { p } : new Promise(() => undefined));
579
+ const first = await Promise.race([asked, elsewhere]);
580
+ if ('result' in first)
581
+ return acpReferences(first.result, record, c.worktree_path, true);
582
+ asked.catch(() => undefined);
583
+ return outcomeFor(first.p, mapped);
516
584
  }
517
585
  finally {
518
- if (method === 'session/request_permission')
519
- transport.resume();
586
+ stop.abort();
587
+ transport.resume();
520
588
  }
521
589
  }
590
+ if (request)
591
+ return acpReferences(await this.#client.request(method, mapped, 0, AbortSignal.any([ctx.signal, session.stop.signal])), record, c.worktree_path, true);
522
592
  await this.#client.notify(method, mapped);
523
593
  };
524
594
  session.transport = transport;
@@ -530,6 +600,9 @@ export class ShardfluxAcp {
530
600
  throw new Error('Agent protocol version changed');
531
601
  mark('initialized');
532
602
  const nativeId = c.harness_session_id ?? record.nativeId;
603
+ // The history a load replays (session/update notifications) reaches the editor under its own session id.
604
+ if (nativeId)
605
+ record.nativeId = nativeId;
533
606
  const result = objectValue(await transport.rpc.request(nativeId ? 'session/load' : 'session/new', { cwd: c.worktree_path, mcpServers: [{ name: 'shardflux', command: '/usr/local/bin/sf-agent', args: ['conversation', 'mcp'], env: [{ name: 'SF_CONVERSATION_ID', value: c.id }] }], ...(nativeId ? { sessionId: nativeId } : {}) }));
534
607
  const id = result.sessionId ?? nativeId;
535
608
  mark('session');
@@ -9,6 +9,9 @@ export declare function readAgentCredentials(opts?: CredentialSources): Promise<
9
9
  app: AgentCredentialApp;
10
10
  files: AgentCredentialFile[];
11
11
  }>>;
12
+ /** The API's fingerprint of a login as this CLI copies it (the API hashes the same canonical file list), so a login on
13
+ * this computer can be told apart from the copy in the cloud without uploading it. */
14
+ export declare function credentialFingerprint(files: readonly AgentCredentialFile[]): string;
12
15
  /** Reusable by the path launcher (L4). Returns metadata only. */
13
16
  export declare function syncAgentCredentials(cloud: Shardflux, opts?: CredentialSources): Promise<import("@shardflux/sdk").AgentCredentialMetadata[]>;
14
17
  export declare const AGENT_CREDENTIAL_HANDLERS: Record<string, Handler>;
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { readFile } from 'node:fs/promises';
2
3
  import { homedir } from 'node:os';
3
4
  import { join } from 'node:path';
@@ -55,6 +56,12 @@ export async function readAgentCredentials(opts = {}) {
55
56
  out.push({ app: 'codex', files: [file('~/.codex/auth.json', object(codex, opts.redact), opts.redact)] });
56
57
  return out;
57
58
  }
59
+ /** The API's fingerprint of a login as this CLI copies it (the API hashes the same canonical file list), so a login on
60
+ * this computer can be told apart from the copy in the cloud without uploading it. */
61
+ export function credentialFingerprint(files) {
62
+ const canonical = [...files].sort((a, b) => a.path.localeCompare(b.path)).map(({ path, content_b64, mode }) => ({ path, content_b64, mode }));
63
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
64
+ }
58
65
  /** Reusable by the path launcher (L4). Returns metadata only. */
59
66
  export async function syncAgentCredentials(cloud, opts = {}) {
60
67
  const sessions = await readAgentCredentials(opts);
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.24.0";
1
+ export declare const CLI_VERSION = "0.25.0";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;