@shardflux/cli 0.16.0 → 0.17.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 (64) hide show
  1. package/CHANGELOG.md +62 -2
  2. package/README.md +43 -5
  3. package/dist/agent-commands.d.ts +9 -1
  4. package/dist/agent-commands.js +65 -16
  5. package/dist/agent-models.d.ts +61 -0
  6. package/dist/agent-models.js +79 -0
  7. package/dist/agent-state.d.ts +10 -0
  8. package/dist/agent-state.js +13 -0
  9. package/dist/agent-target-commands.d.ts +6 -1
  10. package/dist/agent-target-commands.js +117 -16
  11. package/dist/agent-targets.d.ts +2 -0
  12. package/dist/agent-targets.js +10 -4
  13. package/dist/args.d.ts +5 -1
  14. package/dist/args.js +49 -9
  15. package/dist/cli-proxy-forward.d.ts +45 -0
  16. package/dist/cli-proxy-forward.js +432 -0
  17. package/dist/cli-proxy-hub.d.ts +273 -0
  18. package/dist/cli-proxy-hub.js +749 -0
  19. package/dist/cli-proxy.d.ts +325 -0
  20. package/dist/cli-proxy.js +1016 -0
  21. package/dist/codex-cloud.d.ts +2 -1
  22. package/dist/codex-cloud.js +16 -6
  23. package/dist/command-center-actions.js +16 -1
  24. package/dist/conversation-local.js +2 -2
  25. package/dist/conversation-stash.js +4 -3
  26. package/dist/conversation-terminal.d.ts +5 -0
  27. package/dist/conversation-terminal.js +45 -6
  28. package/dist/conversations.d.ts +18 -2
  29. package/dist/conversations.js +12 -5
  30. package/dist/device-approvals.d.ts +12 -0
  31. package/dist/device-approvals.js +73 -0
  32. package/dist/device-helper.d.ts +3 -1
  33. package/dist/device-helper.js +5 -2
  34. package/dist/errors.d.ts +2 -0
  35. package/dist/errors.js +1 -1
  36. package/dist/handlers.js +3 -1
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.js +3 -0
  39. package/dist/linux-helper.d.ts +12 -0
  40. package/dist/linux-helper.js +88 -0
  41. package/dist/linux-terminal.d.ts +11 -0
  42. package/dist/linux-terminal.js +42 -0
  43. package/dist/main.js +29 -6
  44. package/dist/onboarding.d.ts +2 -0
  45. package/dist/onboarding.js +53 -5
  46. package/dist/t3-launch.d.ts +37 -0
  47. package/dist/t3-launch.js +133 -0
  48. package/dist/t3-mode.d.ts +121 -0
  49. package/dist/t3-mode.js +654 -0
  50. package/dist/t3-phone.d.ts +17 -0
  51. package/dist/t3-phone.js +15 -0
  52. package/dist/t3-thread-script.d.ts +18 -0
  53. package/dist/t3-thread-script.js +245 -0
  54. package/dist/terminal-onboarding.d.ts +11 -0
  55. package/dist/terminal-onboarding.js +75 -0
  56. package/dist/thread-on-computer.d.ts +2 -2
  57. package/dist/thread-on-computer.js +13 -7
  58. package/dist/update.d.ts +1 -1
  59. package/dist/update.js +12 -1
  60. package/dist/worktree-twin-engine.d.ts +31 -1
  61. package/dist/worktree-twin-engine.js +253 -40
  62. package/dist/worktree-twin.d.ts +17 -0
  63. package/dist/worktree-twin.js +147 -22
  64. package/package.json +3 -3
@@ -1,13 +1,14 @@
1
1
  import type { CodexCloudTask } from '@shardflux/sdk';
2
2
  import type { Ctx, Handler } from './commands.js';
3
3
  import type { Values } from './args.js';
4
+ import type { ModelChoice } from './agent-models.js';
4
5
  import type { Conversation } from './conversations.js';
5
6
  import type { InitialImage } from './initial-prompt.js';
6
7
  /** Only provider-returned task ids enter the registered Codex deep-link route. */
7
8
  export declare function openCodexThread(task: CodexCloudTask, env: Ctx['io']['env']): Promise<void>;
8
9
  export declare function submitCloudInitialPrompt(ctx: Ctx, c: Conversation, prompt: string, workspaceKey: string): Promise<void>;
9
10
  /** Prints the Cloud thread (and its link) and opens it in the Codex app; --json prints it instead. */
10
- export declare function printCloudThread(ctx: Ctx, c: Conversation, task: CodexCloudTask, submitted?: boolean): Promise<void>;
11
+ export declare function printCloudThread(ctx: Ctx, c: Conversation, task: CodexCloudTask, submitted?: boolean, choice?: ModelChoice): Promise<void>;
11
12
  /** How long the account's Codex remote device may take to come up before it counts as stuck. */
12
13
  export declare const CODEX_STARTING_LIMIT_MS: number;
13
14
  export type CodexConnectorView = {
@@ -2,7 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { CodedError, UsageError } from "./errors.js";
3
3
  import { json } from "./format.js";
4
4
  import { syncAgentCredentials } from "./agent-credentials.js";
5
- import { createConversation, resumeConversation } from "./conversations.js";
5
+ import { createConversation, getConversation, recordedChoice, resumeConversation } from "./conversations.js";
6
+ import { CHOICE_PENDING, choiceReport, hasChoice, modelChoice } from "./agent-models.js";
6
7
  import { AgentStateStore } from "./agent-state.js";
7
8
  import { ensureProjectGit } from "./project-git.js";
8
9
  import { gitRunner } from "./agent-git.js";
@@ -33,16 +34,24 @@ export async function submitCloudInitialPrompt(ctx, c, prompt, workspaceKey) {
33
34
  }
34
35
  }
35
36
  /** Prints the Cloud thread (and its link) and opens it in the Codex app; --json prints it instead. */
36
- export async function printCloudThread(ctx, c, task, submitted = false) { return output(ctx, c, task, submitted); }
37
- async function output(ctx, c, task, submitted = false) {
37
+ export async function printCloudThread(ctx, c, task, submitted = false, choice) { return output(ctx, c, task, submitted, choice); }
38
+ async function output(ctx, c, task, submitted = false, choice) {
38
39
  const url = threadLink(task);
40
+ // model_choice (0.17.0+): an admitted thread's record keeps the model and effort only when Cloud started it with them.
41
+ const applied = hasChoice(choice) ? await cloudChoiceApplied(ctx, c, choice) : true;
39
42
  if (ctx.json) {
40
- ctx.io.stdout.write(json({ conversation: c, cloud: task, url, ...(submitted ? { initial_prompt: { submitted: true } } : {}) }));
43
+ ctx.io.stdout.write(json({ conversation: c, cloud: task, url, ...(submitted ? { initial_prompt: { submitted: true } } : {}), ...(hasChoice(choice) ? { model_choice: choiceReport(choice, applied) } : {}) }));
41
44
  return;
42
45
  }
46
+ if (!applied)
47
+ ctx.io.stderr.write(`${CHOICE_PENDING}\n`);
43
48
  ctx.io.stdout.write(`${c.key}\n${task.desktop_url}\n`);
44
49
  await openCodexThread(task, ctx.io.env);
45
50
  }
51
+ async function cloudChoiceApplied(ctx, c, choice) {
52
+ const kept = recordedChoice(await getConversation(ctx, c.id));
53
+ return kept.model === choice.model && kept.effort === choice.effort;
54
+ }
46
55
  /** How long the account's Codex remote device may take to come up before it counts as stuck. */
47
56
  export const CODEX_STARTING_LIMIT_MS = 5 * 60_000;
48
57
  /** When a connector generation started: its UUIDv7 time. */
@@ -114,11 +123,12 @@ export async function launchCodexCloud(ctx, values, path, images = []) {
114
123
  throw new UsageError('New Codex conversations open in Codex Cloud. Omit --in or use --in codex-app.');
115
124
  if (values.like !== undefined)
116
125
  throw new UsageError('Choose the project directory with --path; new Codex conversations use its latest machine template.');
126
+ const choice = modelChoice(values, 'codex');
117
127
  const project = projectIdentity(path);
118
128
  await ensureProjectGit(ctx, project.folder);
119
129
  await gitRunner(project.folder, { ...process.env, ...ctx.io.env }, ctx.signal)(['rev-parse', 'HEAD']);
120
130
  await ensureCodexCloud(ctx);
121
- const created = await createConversation(ctx, path, 'codex', 'codex-cloud', typeof values.name === 'string' ? values.name : undefined);
131
+ const created = await createConversation(ctx, path, 'codex', 'codex-cloud', typeof values.name === 'string' ? values.name : undefined, choice);
122
132
  const c = created.conversation;
123
133
  // --computer: the conversation VM's desktop is on before Codex starts, so the thread gets the computer tools.
124
134
  if (values.computer === true)
@@ -132,7 +142,7 @@ export async function launchCodexCloud(ctx, values, path, images = []) {
132
142
  const submitted = typeof values.initialPrompt === 'string';
133
143
  if (submitted)
134
144
  await submitCloudInitialPrompt(ctx, c, values.initialPrompt, created.workspace.key);
135
- await output(ctx, c, task, submitted);
145
+ await output(ctx, c, task, submitted, choice);
136
146
  return 0;
137
147
  }
138
148
  export async function openCodexConversation(ctx, c) {
@@ -11,8 +11,9 @@ import { agentName as gitAgentName } from "./agent-git.js";
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { prepareAgentMachine } from "./agent-machine.js";
13
13
  import { OperationFailedError } from '@shardflux/sdk';
14
- import { launchConversationProject } from "./conversation-terminal.js";
14
+ import { launchConversationProject, launchProjectCommand } from "./conversation-terminal.js";
15
15
  import { startThreadOn } from "./thread-on-computer.js";
16
+ import { launchT3 } from "./t3-launch.js";
16
17
  /** L9 can register target-specific handlers after detecting the installed applications. */
17
18
  export async function availableTargets(ctx) {
18
19
  const { HANDLERS } = await import("./handlers.js");
@@ -35,6 +36,20 @@ export async function agentAction(ctx, action, values, args) {
35
36
  }
36
37
  export const CENTER_HANDLERS = {
37
38
  async 'agents new'(ctx, values, [ref]) {
39
+ const fresh = !ref && values.name === undefined && values.like === undefined;
40
+ // T3 Code mode (0.17.0+): --in t3 starts the first message as a T3 thread (--on: on that computer's T3 server);
41
+ // --in auto opens where open_in says.
42
+ if (fresh && values.in === 't3')
43
+ return launchT3(ctx, values, str(values.path) ?? './', str(values.app));
44
+ if (fresh && values.in === 'auto') {
45
+ const next = { ...values };
46
+ delete next.in;
47
+ return launchProjectCommand(ctx, next, [str(values.path) ?? './', ...(str(values.app) ? [str(values.app)] : [])]);
48
+ }
49
+ if (values.in === 'auto') {
50
+ values = { ...values };
51
+ delete values.in;
52
+ }
38
53
  // A thread on an existing conversation's computer (0.16.0+).
39
54
  if (values.on !== undefined) {
40
55
  if (ref || values.like !== undefined)
@@ -1,5 +1,5 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
- import { getConversation, resolveConversation, conversationWorkspace, localConversations } from "./conversations.js";
2
+ import { conversationEnded, getConversation, resolveConversation, conversationWorkspace, localConversations } from "./conversations.js";
3
3
  import { ensureConversationWorktree, devicePort } from "./device-bridge.js";
4
4
  import { startProjectSync } from "./project-sync.js";
5
5
  import { projectIdentity } from "./project-identity.js";
@@ -48,7 +48,7 @@ export const CONVERSATION_HANDLERS = {
48
48
  try {
49
49
  while (!ctx.signal.aborted) {
50
50
  const next = await getConversation(ctx, c.id);
51
- if (next.state === 'done' || next.state === 'archived' || next.workspace_id !== c.workspace_id)
51
+ if (conversationEnded(next) || next.workspace_id !== c.workspace_id)
52
52
  break;
53
53
  await delay(1000, undefined, { signal: ctx.signal }).catch(() => undefined);
54
54
  }
@@ -182,7 +182,7 @@ export async function mirrorProjectConversations(ctx, state = new ConversationMi
182
182
  const c = all.find(c => c.id === record.conversation.id);
183
183
  if (!c || record.handed_off || !c.harness_session_id)
184
184
  continue;
185
- const revision = JSON.stringify([c.workspace_id, c.harness_session_id, c.state, c.durable, c.last_activity_at, c.summary]);
185
+ const revision = JSON.stringify([c.workspace_id, c.harness_session_id, c.state, c.durable, c.last_activity_at, c.summary, c.ended_at]);
186
186
  if (state.mirrored.get(c.id) === revision)
187
187
  continue;
188
188
  let retry = state.retries.get(c.id);
@@ -220,8 +220,9 @@ export async function mirrorProjectConversations(ctx, state = new ConversationMi
220
220
  await mirrorConversation(ctx, { ...record, conversation: c }, source, skipped);
221
221
  retry.failures = 0;
222
222
  retry.retryAt = 0;
223
- // An ended record may precede its VM tombstone; retry until the handoff receipt is written.
224
- if (c.state !== 'done' && c.state !== 'archived')
223
+ // An ended record may precede its VM tombstone; retry until the handoff receipt is written. A finished turn is
224
+ // `done` while its VM lives: mirrored once per revision, and again when it ends.
225
+ if (c.state !== 'archived' && !(c.state === 'done' && c.ended_at))
225
226
  state.mirrored.set(c.id, revision);
226
227
  }
227
228
  catch (e) {
@@ -4,5 +4,10 @@ import type { Values } from './args.js';
4
4
  import type { Conversation } from './conversations.js';
5
5
  /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
6
6
  export declare function conversationTerminal(ctx: Ctx, ws: Workspace, c: Conversation, project: string, values: Values): Promise<number>;
7
+ /**
8
+ * `shard ./` (and `agents new --in auto`): new conversations open where `agents targets` says (open_in, 0.17.0+): T3
9
+ * Code mode, Claude Desktop or claude.ai for Claude (the conversation's Remote Control session), else this terminal.
10
+ */
11
+ export declare const launchProjectCommand: Handler;
7
12
  /** Select a durable conversation, then use the harness's native terminal for all interaction. */
8
13
  export declare const launchConversationProject: Handler;
@@ -1,7 +1,9 @@
1
+ import { linuxTerminalSession } from "./linux-terminal.js";
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { AgentStateStore } from "./agent-state.js";
3
4
  import { projectIdentity, projectLabels } from "./project-identity.js";
4
- import { createConversation, conversationName, conversationWorkspace, conversationsUnavailable, listConversations, localConversations, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
5
+ import { createConversation, conversationName, conversationWorkspace, conversationsUnavailable, listConversations, localConversations, recordedChoice, rememberConversation, resumeConversation, restoreConversationBinding } from "./conversations.js";
6
+ import { modelChoice } from "./agent-models.js";
5
7
  import { terminal } from "./agent-commands.js";
6
8
  import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
7
9
  import { syncAgentCredentials } from "./agent-credentials.js";
@@ -16,6 +18,11 @@ import { agentReachability } from "./agent-reachability.js";
16
18
  import { promptWithImages, readInitialImages, readInitialPrompt, uploadInitialImages } from "./initial-prompt.js";
17
19
  import { launchCodexCloud, openCodexConversation } from "./codex-cloud.js";
18
20
  import { startThreadOn } from "./thread-on-computer.js";
21
+ import { openInChoices } from "./t3-mode.js";
22
+ import { launchT3 } from "./t3-launch.js";
23
+ import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
24
+ import { openUrl } from "./browser.js";
25
+ import { out } from "./commands.js";
19
26
  /** Every terminal entry point uses the durable conversation's native harness, worktree and session. */
20
27
  export async function conversationTerminal(ctx, ws, c, project, values) {
21
28
  if (c.route === 'codex-cloud')
@@ -23,10 +30,28 @@ export async function conversationTerminal(ctx, ws, c, project, values) {
23
30
  await restoreConversationBinding(ctx, c, ws);
24
31
  // A thread on another conversation's computer has its own PTY there, never the VM's own (0.16.0+).
25
32
  const pty = ws.labels.shard_conversation && ws.labels.shard_conversation !== c.id ? conversationName(c) : undefined;
26
- return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}) });
33
+ return terminal(ctx, ws, c.harness, conversationName(c), project, values, undefined, c.worktree_path, { id: c.id, session: c.harness_session_id, title: c.title, ...(pty ? { pty } : {}), choice: recordedChoice(c) });
27
34
  }
35
+ /**
36
+ * `shard ./` (and `agents new --in auto`): new conversations open where `agents targets` says (open_in, 0.17.0+): T3
37
+ * Code mode, Claude Desktop or claude.ai for Claude (the conversation's Remote Control session), else this terminal.
38
+ */
39
+ export const launchProjectCommand = async (ctx, values, [path = './', explicit]) => {
40
+ const named = explicit ?? (values.claude === true ? 'claude' : values.codex === true ? 'codex' : undefined);
41
+ if (values.resume !== true && values.review !== true) {
42
+ const project = projectIdentity(path);
43
+ const app = named === 'claude' || named === 'codex' ? named : new AgentStateStore(ctx.store.dir).app(project.remote ?? project.folder);
44
+ const where = process.platform === 'linux' && ctx.io.stdin.isTTY ? 'terminal' : openInChoices(ctx, project.slug)[app];
45
+ if (where === 't3')
46
+ return launchT3(ctx, values, path, app);
47
+ if (values.on === undefined && app === 'claude' && (where === 'claude-desktop' || where === 'web'))
48
+ return startConversation(ctx, values, [path, ...(explicit ? [explicit] : [])], where);
49
+ }
50
+ return launchConversationProject(ctx, values, [path, ...(explicit ? [explicit] : [])]);
51
+ };
28
52
  /** Select a durable conversation, then use the harness's native terminal for all interaction. */
29
- export const launchConversationProject = async (ctx, values, [path = './', explicit]) => {
53
+ export const launchConversationProject = (ctx, values, args) => startConversation(ctx, values, args);
54
+ async function startConversation(ctx, values, [path = './', explicit], openIn) {
30
55
  if (values.on !== undefined) {
31
56
  const app = explicit ?? (values.claude === true ? 'claude' : values.codex === true ? 'codex' : undefined);
32
57
  return startThreadOn(ctx, values, path, app);
@@ -42,6 +67,8 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
42
67
  const app = explicit === 'claude' || explicit === 'codex' ? explicit : store.app(project.remote ?? project.folder);
43
68
  if (explicit && explicit !== app)
44
69
  throw new UsageError('Choose claude or codex.');
70
+ // Checked before a VM is allocated; the new conversation's record carries it to its guest (0.17.0+).
71
+ const choice = modelChoice(values, app);
45
72
  if (values.resume === true) {
46
73
  // The native pickers are VM-local. C-K6's project picker must select the binding first.
47
74
  const records = localConversations(ctx);
@@ -62,7 +89,7 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
62
89
  const ws = await conversationWorkspace(ctx, next);
63
90
  return conversationTerminal(ctx, ws, next, project.remote ?? project.folder, values);
64
91
  }
65
- if (app === 'codex')
92
+ if (app === 'codex' && !linuxTerminalSession(ctx, values))
66
93
  return launchCodexCloud(ctx, values, path, images);
67
94
  await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
68
95
  const signedIn = app === 'claude' ? await cloudClaudeLogin(ctx) : true;
@@ -70,7 +97,7 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
70
97
  requireClaudeTerminal(ctx);
71
98
  let created;
72
99
  try {
73
- created = await createConversation(ctx, project.folder, app, 'terminal');
100
+ created = await createConversation(ctx, project.folder, app, 'terminal', undefined, choice);
74
101
  }
75
102
  catch (e) {
76
103
  if (!conversationsUnavailable(e))
@@ -102,5 +129,17 @@ export const launchConversationProject = async (ctx, values, [path = './', expli
102
129
  if (!(await savedMachine(ctx))?.template)
103
130
  await syncAgentConfig(ctx, ws);
104
131
  await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
132
+ if (openIn) {
133
+ // The agent starts detached in its VM; the conversation then opens in Claude Desktop or claude.ai (Remote Control).
134
+ const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
135
+ await conversationTerminal(quiet, ws, c, project.remote ?? project.folder, { ...values, detach: true });
136
+ if (openIn === 'claude-desktop')
137
+ return openClaudeDesktopSession(ctx, ws, c);
138
+ const url = await remoteControlUrl(ctx, ws, c);
139
+ if (!ctx.json && !openUrl(url, ctx.io.env))
140
+ throw new Error(`could not open ${url}`);
141
+ out(ctx, `${url}\n`, { target: 'web', workspace: ws.data, conversation_id: c.id, url });
142
+ return 0;
143
+ }
105
144
  return conversationTerminal(ctx, ws, c, project.remote ?? project.folder, values);
106
- };
145
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Workspace, WorkspaceView } from '@shardflux/sdk';
2
2
  import type { Ctx } from './commands.js';
3
+ import type { ModelChoice } from './agent-models.js';
3
4
  export type Harness = 'claude' | 'codex';
4
5
  export interface Conversation {
5
6
  id: string;
@@ -17,6 +18,11 @@ export interface Conversation {
17
18
  state: 'working' | 'needs_you' | 'unresolved' | 'done' | 'archived';
18
19
  created_at: string;
19
20
  ended_at: string | null;
21
+ landed?: {
22
+ how: string;
23
+ at: string;
24
+ ref: string;
25
+ } | null;
20
26
  last_activity_at?: string;
21
27
  durable?: {
22
28
  branch_sha: string;
@@ -31,6 +37,9 @@ export interface Conversation {
31
37
  base_branch?: string | null;
32
38
  role?: string;
33
39
  join_mode?: string | null;
40
+ /** The model and effort its harness starts with; null is the agent's own default (0.17.0+, APIs that keep them). */
41
+ model?: string | null;
42
+ effort?: string | null;
34
43
  }
35
44
  export interface LocalConversation {
36
45
  api_url: string;
@@ -41,6 +50,11 @@ export interface LocalConversation {
41
50
  /** The branch the conversation started from (0.16.0+), for APIs that do not return base_branch yet. */
42
51
  base_branch?: string;
43
52
  }
53
+ /**
54
+ * Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
55
+ * while its VM still takes the next turn; on Codex Cloud, `done` ends the conversation.
56
+ */
57
+ export declare const conversationEnded: (c: Pick<Conversation, "state" | "ended_at" | "route" | "landed">) => boolean;
44
58
  export declare const conversationName: (c: Conversation) => string;
45
59
  export declare function validateConversation(c: Conversation): Conversation;
46
60
  export declare function localConversations(ctx: Pick<Ctx, 'store' | 'apiUrl'>): Record<string, LocalConversation>;
@@ -57,9 +71,11 @@ export declare function conversationWorkspace(ctx: Ctx, c: Conversation): Promis
57
71
  */
58
72
  export declare function waitThreadReady(ctx: Ctx, c: Conversation, pollMs?: number): Promise<Conversation>;
59
73
  export declare function restoreConversationBinding(ctx: Ctx, c: Conversation, ws: Workspace): Promise<void>;
60
- export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string): Promise<Conversation>;
61
- export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string): Promise<{
74
+ export declare function createConversationRecord(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, allocated?: (workspace: WorkspaceView) => void, idempotencyKey?: string, choice?: ModelChoice): Promise<Conversation>;
75
+ export declare function createConversation(ctx: Ctx, folder: string, harness: Harness, route: Conversation['route'], name?: string, choice?: ModelChoice): Promise<{
62
76
  conversation: Conversation;
63
77
  workspace: Workspace;
64
78
  }>;
65
79
  export declare function resumeConversation(ctx: Ctx, c: Conversation): Promise<Conversation>;
80
+ /** The model and effort a conversation's harness starts with, as its record keeps them. */
81
+ export declare const recordedChoice: (c: Pick<Conversation, "model" | "effort">) => ModelChoice;
@@ -9,6 +9,11 @@ import { UsageError } from "./errors.js";
9
9
  import { conversationClient } from "./conversation-commands.js";
10
10
  import { recordSeedSnapshot } from "./files-mirror.js";
11
11
  import { rememberTwinSeed, sendTwinSeed, twinCheckoutArgv, twinSeed } from "./worktree-twin.js";
12
+ /**
13
+ * Whether the conversation has ended: its VM was reclaimed, or it was archived or discarded. A finished turn is `done`
14
+ * while its VM still takes the next turn; on Codex Cloud, `done` ends the conversation.
15
+ */
16
+ export const conversationEnded = (c) => !!c.ended_at || c.state === 'archived' || c.landed?.how === 'discarded' || (c.route === 'codex-cloud' && c.state === 'done');
12
17
  export const conversationName = (c) => {
13
18
  const name = c.key.split('/').at(-1);
14
19
  if (!/^[a-z0-9][a-z0-9-]{0,47}$/.test(name) || c.branch !== `shard/${name}`)
@@ -116,7 +121,7 @@ export async function restoreConversationBinding(ctx, c, ws) {
116
121
  if (result.exitCode !== 0)
117
122
  throw new Error(`Conversation restore failed: ${result.stderr.trim()}`);
118
123
  }
119
- export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID()) {
124
+ export async function createConversationRecord(ctx, folder, harness, route, name, allocated, idempotencyKey = randomUUID(), choice = {}) {
120
125
  const project = projectIdentity(folder);
121
126
  await ensureProjectGit(ctx, project.folder);
122
127
  // Both the conversation API and its legacy workspace fallback need a usable local HEAD.
@@ -128,16 +133,16 @@ export async function createConversationRecord(ctx, folder, harness, route, name
128
133
  const seed = await twinSeed(ctx.store.dir, project.folder, head, ctx.store.profile(ctx.apiUrl).project_id).catch(() => ({ commit: head, snapshot: false }));
129
134
  const commit = seed.commit;
130
135
  const response = await conversationClient(ctx).conversations.create({ repoRemote: project.remote ?? `file://${project.folder}`, harness, route, mode: 'new',
131
- base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
136
+ base: { commit, ...(branch ? { branch } : {}) }, ...(name ? { name } : {}), ...choice, projectId: ctx.store.profile(ctx.apiUrl).project_id }, { idempotencyKey, signal: ctx.signal });
132
137
  const c = validateConversation({ ...response.conversation, restore: response.restore });
133
138
  allocated?.(response.workspace);
134
139
  rememberTwinSeed(ctx.store.dir, c.id, project.folder, seed, branch);
135
140
  rememberConversation(ctx, c, project.folder, false, branch);
136
141
  return c;
137
142
  }
138
- export async function createConversation(ctx, folder, harness, route, name) {
143
+ export async function createConversation(ctx, folder, harness, route, name, choice = {}) {
139
144
  const project = projectIdentity(folder);
140
- const c = await createConversationRecord(ctx, project.folder, harness, route, name);
145
+ const c = await createConversationRecord(ctx, project.folder, harness, route, name, undefined, undefined, choice);
141
146
  try {
142
147
  const ws = await conversationWorkspace(ctx, c);
143
148
  // This is a one-time seed. A conversation never watches or writes the owner's checkout.
@@ -163,8 +168,10 @@ export async function createConversation(ctx, folder, harness, route, name) {
163
168
  }
164
169
  }
165
170
  export async function resumeConversation(ctx, c) {
166
- if (c.state !== 'done' && c.state !== 'archived')
171
+ if (!conversationEnded(c))
167
172
  return getConversation(ctx, c.id);
168
173
  const response = await conversationClient(ctx).conversations.resume(c.id, { idempotencyKey: randomUUID(), signal: ctx.signal });
169
174
  return validateConversation({ ...response.conversation, restore: response.restore });
170
175
  }
176
+ /** The model and effort a conversation's harness starts with, as its record keeps them. */
177
+ export const recordedChoice = (c) => ({ ...(c.model ? { model: c.model } : {}), ...(c.effort ? { effort: c.effort } : {}) });
@@ -0,0 +1,12 @@
1
+ import type { DeviceApproval, DeviceChoice } from './device-bridge.js';
2
+ import type { Ctx, Handler } from './commands.js';
3
+ type Pending = DeviceApproval & {
4
+ nonce: string;
5
+ pid: number;
6
+ };
7
+ export declare function pendingDeviceApprovals(dir: string): Pending[];
8
+ export declare function answerDeviceApproval(dir: string, id: string, choice: DeviceChoice): void;
9
+ /** Local user-owned files carry approvals; cloud data can never answer its own request. */
10
+ export declare function askLocalDeviceApproval(ctx: Ctx, approval: DeviceApproval): Promise<DeviceChoice>;
11
+ export declare const DEVICE_HANDLERS: Record<string, Handler>;
12
+ export {};
@@ -0,0 +1,73 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { readdirSync, readFileSync, rmSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { setTimeout as delay } from 'node:timers/promises';
6
+ import { writePrivateFile } from "./credentials.js";
7
+ import { UsageError } from "./errors.js";
8
+ import { json } from "./format.js";
9
+ const paths = (dir, id) => {
10
+ if (!/^[a-zA-Z0-9_-]{1,96}$/.test(id))
11
+ throw new UsageError('Invalid device request id.');
12
+ return { pending: join(dir, 'device-approvals', `${id}.json`), answer: join(dir, 'device-answers', `${id}.json`) };
13
+ };
14
+ export function pendingDeviceApprovals(dir) {
15
+ let names;
16
+ try {
17
+ names = readdirSync(join(dir, 'device-approvals'));
18
+ }
19
+ catch {
20
+ return [];
21
+ }
22
+ return names.filter(n => n.endsWith('.json')).flatMap(n => {
23
+ try {
24
+ const p = JSON.parse(readFileSync(paths(dir, n.slice(0, -5)).pending, 'utf8'));
25
+ process.kill(p.pid, 0);
26
+ return [p];
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ });
32
+ }
33
+ export function answerDeviceApproval(dir, id, choice) {
34
+ const pending = pendingDeviceApprovals(dir).find(p => p.request_id === id);
35
+ if (!pending)
36
+ throw new UsageError('That request is no longer waiting for approval. Run shard device requests.');
37
+ writePrivateFile(join(dir, 'device-answers'), paths(dir, id).answer, JSON.stringify({ nonce: pending.nonce, choice }));
38
+ }
39
+ /** Local user-owned files carry approvals; cloud data can never answer its own request. */
40
+ export async function askLocalDeviceApproval(ctx, approval) {
41
+ const file = paths(ctx.store.dir, approval.request_id), nonce = randomUUID();
42
+ rmSync(file.answer, { force: true });
43
+ writePrivateFile(join(ctx.store.dir, 'device-approvals'), file.pending, JSON.stringify({ ...approval, nonce, pid: process.pid }));
44
+ spawnSync('notify-send', ['--', 'Shardflux approval', `${approval.summary}\nReview: shard device requests`], { env: ctx.io.env, stdio: 'ignore', timeout: 2000 });
45
+ try {
46
+ while (!ctx.signal.aborted) {
47
+ try {
48
+ const answer = JSON.parse(readFileSync(file.answer, 'utf8'));
49
+ if (answer.nonce === nonce && ['once', 'always', 'deny'].includes(answer.choice))
50
+ return answer.choice;
51
+ }
52
+ catch { /* wait for the local CLI */ }
53
+ await delay(200, undefined, { signal: ctx.signal }).catch(() => undefined);
54
+ }
55
+ return 'deny';
56
+ }
57
+ finally {
58
+ rmSync(file.pending, { force: true });
59
+ rmSync(file.answer, { force: true });
60
+ }
61
+ }
62
+ export const DEVICE_HANDLERS = {
63
+ 'device requests'(ctx) {
64
+ const requests = pendingDeviceApprovals(ctx.store.dir);
65
+ ctx.io.stdout.write(ctx.json ? json({ requests }) : requests.length ? requests.map(r => `${r.request_id}: ${r.summary}\n Project: ${r.project_name ?? r.project_id}; conversation: ${r.conversation_title ?? r.conversation_id}\n Local: ${r.local_path ?? '(none)'}; remote: ${r.remote_path ?? '(none)'}${r.argv ? `\n Command: ${JSON.stringify(r.argv)}` : ''}${r.local_port ? `\n Ports: ${r.local_port} → ${r.remote_port}` : ''}\n shard device approve ${r.request_id} | shard device deny ${r.request_id}\n`).join('') : 'No device requests waiting for approval.\n');
66
+ return Promise.resolve(0);
67
+ },
68
+ ...Object.fromEntries(['approve', 'deny'].map(action => [`device ${action}`, (ctx, _values, [id]) => {
69
+ answerDeviceApproval(ctx.store.dir, id, action === 'approve' ? 'once' : 'deny');
70
+ ctx.io.stdout.write(ctx.json ? json({ request_id: id, choice: action }) : `Device request ${id}: ${action === 'approve' ? 'approved once' : 'denied'}.\n`);
71
+ return Promise.resolve(0);
72
+ }])),
73
+ };
@@ -1,3 +1,5 @@
1
1
  import type { Ctx } from './commands.js';
2
2
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
3
- export declare function runDeviceHelper(ctx: Ctx): Promise<number>;
3
+ export declare function runDeviceHelper(ctx: Ctx, options?: {
4
+ service?: boolean;
5
+ }): Promise<number>;
@@ -12,13 +12,14 @@ import { writePrivateFile } from "./credentials.js";
12
12
  import { conversationClient } from "./conversation-commands.js";
13
13
  import { Readable } from 'node:stream';
14
14
  import { runWorktreeTwin } from "./worktree-twin-engine.js";
15
+ import { askLocalDeviceApproval } from "./device-approvals.js";
15
16
  /** The menu bar owns this helper and its stdin; VM data can request actions but cannot answer approvals. */
16
- export async function runDeviceHelper(ctx) {
17
+ export async function runDeviceHelper(ctx, options = {}) {
17
18
  const abort = new AbortController(), signal = AbortSignal.any([ctx.signal, abort.signal]);
18
19
  ctx = { ...ctx, signal };
19
20
  const pending = new Map();
20
21
  const inputSource = Readable.from(ctx.io.stdin, { objectMode: false, signal });
21
- const input = (async () => {
22
+ const input = options.service ? Promise.resolve() : (async () => {
22
23
  let tail = '';
23
24
  for await (const bytes of inputSource) {
24
25
  tail += Buffer.from(bytes).toString();
@@ -41,6 +42,8 @@ export async function runDeviceHelper(ctx) {
41
42
  const acp = await askConnectedAcp(ctx.store.dir, approval, signal);
42
43
  if (acp)
43
44
  return acp;
45
+ if (options.service)
46
+ return askLocalDeviceApproval(ctx, approval);
44
47
  return new Promise(resolve => {
45
48
  const denied = () => { pending.delete(approval.request_id); resolve('deny'); };
46
49
  signal.addEventListener('abort', denied, { once: true });
package/dist/errors.d.ts CHANGED
@@ -114,6 +114,8 @@ export declare class ExecSessionRunningError extends HintedError {
114
114
  export interface ErrorInfo {
115
115
  code: string;
116
116
  message: string;
117
+ /** A refusal `shard` detects itself (CodedError, 0.17.0+): the next step, as plain text (the `hint:` line). */
118
+ fix?: string;
117
119
  status?: number;
118
120
  request_id?: string;
119
121
  retryable?: boolean;
package/dist/errors.js CHANGED
@@ -241,7 +241,7 @@ export function describeError(err) {
241
241
  if (err instanceof GitError)
242
242
  return { code: 'git_error', message: err.message, retryable: false };
243
243
  if (err instanceof CodedError)
244
- return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}) };
244
+ return { code: err.code, message: err.message, retryable: false, ...(err.hint ? { fix: err.hint } : {}), ...(err.details ? { details: err.details } : {}) };
245
245
  if (err instanceof CheckoutTimeoutError) {
246
246
  return { code: 'timeout', message: `${err.message}; the checkout stays open (pay at its URL, then check with: shard billing status)`, last_state: err.checkout.status, details: { checkout_id: err.checkout.id, status: err.checkout.status, url: err.checkout.url } };
247
247
  }
package/dist/handlers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { DEVICE_HANDLERS } from "./device-approvals.js";
1
2
  import { CREDENTIAL_HANDLERS } from "./credential-prompts.js";
2
3
  import { CONVERSATION_HANDLERS } from "./conversation-commands.js";
3
4
  /**
@@ -18,4 +19,5 @@ import { CONVERSATION_HANDLERS as LOCAL_CONVERSATION_HANDLERS } from "./conversa
18
19
  import { TWIN_HANDLERS } from "./worktree-twin-engine.js";
19
20
  import { CODEX_CLOUD_HANDLERS } from "./codex-cloud.js";
20
21
  import { THREAD_HANDLERS } from "./thread-on-computer.js";
21
- export const HANDLERS = { ...CODEX_CLOUD_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
22
+ import { CLI_PROXY_HANDLERS } from "./cli-proxy.js";
23
+ export const HANDLERS = { ...DEVICE_HANDLERS, ...CODEX_CLOUD_HANDLERS, ...CLI_PROXY_HANDLERS, ...THREAD_HANDLERS, ...CONVERSATION_HANDLERS, ...LOCAL_CONVERSATION_HANDLERS, ...TWIN_HANDLERS, ...CREDENTIAL_HANDLERS, ...AGENT_CREDENTIAL_HANDLERS, ...KEY_HANDLERS, ...ACCOUNT_HANDLERS, ...AGENT_HANDLERS, ...CENTER_HANDLERS, ...TARGET_HANDLERS, ...AGENT_GIT_HANDLERS, ...MENUBAR_HANDLERS, ...FILESYSTEM_HANDLERS, ...UPDATE_HANDLERS };
package/dist/index.d.ts CHANGED
@@ -24,4 +24,8 @@ export type { ErrorInfo } from './errors.js';
24
24
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
25
25
  export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from './timing.js';
26
26
  export { readAgentCredentials, syncAgentCredentials } from './agent-credentials.js';
27
+ export { detectLocalCliProxy, importCliProxy } from './cli-proxy.js';
28
+ export type { ImportOptions, ImportResult, LocalCliProxy, LocalLogin } from './cli-proxy.js';
29
+ export { ensureCliProxyHub, readHubInfo } from './cli-proxy-hub.js';
30
+ export type { EnsureOptions, EnsureResult, HubInfo } from './cli-proxy-hub.js';
27
31
  export type { CredentialSources } from './agent-credentials.js';
package/dist/index.js CHANGED
@@ -16,3 +16,6 @@ export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, Execut
16
16
  export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
17
17
  export { TimingRecorder, coldBootNotice, hostLostNotice, lostSuspendNotice, notices, timingJson, timingText } from "./timing.js";
18
18
  export { readAgentCredentials, syncAgentCredentials } from "./agent-credentials.js";
19
+ // Hosted CLIProxyAPI (onboarding offers the import when detectLocalCliProxy() finds logins).
20
+ export { detectLocalCliProxy, importCliProxy } from "./cli-proxy.js";
21
+ export { ensureCliProxyHub, readHubInfo } from "./cli-proxy-hub.js";
@@ -0,0 +1,12 @@
1
+ import type { Ctx } from './commands.js';
2
+ export declare const HELPER_UNIT = "shardflux-helper.service";
3
+ export declare const helperUnitPath: (home: string) => string;
4
+ /** No credentials in the unit: the helper reads the same private store as shard. */
5
+ export declare function helperUnit(ctx: Pick<Ctx, 'runtime' | 'store' | 'apiUrl' | 'io'>): string;
6
+ export type Systemctl = (args: string[]) => boolean;
7
+ export declare function installLinuxHelper(ctx: Ctx, deps?: {
8
+ platform?: NodeJS.Platform;
9
+ run?: Systemctl;
10
+ }): boolean;
11
+ /** The caller owns fallback lifetime; never leave an unmanaged background process behind. */
12
+ export declare function startLinuxHelper(ctx: Ctx): () => Promise<void>;