@shardflux/cli 0.18.1 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +27 -0
  3. package/dist/account-args.js +1 -0
  4. package/dist/agent-commands.d.ts +1 -0
  5. package/dist/agent-commands.js +15 -5
  6. package/dist/agent-config-files.d.ts +16 -2
  7. package/dist/agent-config-files.js +48 -61
  8. package/dist/agent-config-sync.d.ts +12 -1
  9. package/dist/agent-config-sync.js +228 -54
  10. package/dist/agent-repository.d.ts +39 -9
  11. package/dist/agent-repository.js +309 -92
  12. package/dist/agent-state.d.ts +3 -0
  13. package/dist/agent-state.js +13 -0
  14. package/dist/agent-target-commands.d.ts +2 -0
  15. package/dist/agent-target-commands.js +45 -15
  16. package/dist/args.d.ts +1 -1
  17. package/dist/args.js +14 -2
  18. package/dist/conversation-router.js +7 -11
  19. package/dist/conversation-stash.js +64 -18
  20. package/dist/conversation-terminal.d.ts +1 -1
  21. package/dist/conversation-terminal.js +9 -10
  22. package/dist/conversations.d.ts +9 -2
  23. package/dist/conversations.js +23 -6
  24. package/dist/errors.d.ts +1 -1
  25. package/dist/errors.js +1 -1
  26. package/dist/files-mirror.d.ts +1 -9
  27. package/dist/files-mirror.js +19 -3
  28. package/dist/format.js +37 -2
  29. package/dist/handlers.js +3 -1
  30. package/dist/initial-prompt.d.ts +15 -2
  31. package/dist/initial-prompt.js +29 -4
  32. package/dist/local-sessions.d.ts +60 -0
  33. package/dist/local-sessions.js +445 -0
  34. package/dist/main.js +12 -0
  35. package/dist/move.d.ts +44 -0
  36. package/dist/move.js +248 -0
  37. package/dist/moved-sessions.d.ts +33 -0
  38. package/dist/moved-sessions.js +91 -0
  39. package/dist/onboarding.d.ts +18 -3
  40. package/dist/onboarding.js +72 -46
  41. package/dist/project-sync-manifest.d.ts +31 -3
  42. package/dist/project-sync-manifest.js +192 -108
  43. package/dist/project-sync-transfer.d.ts +50 -4
  44. package/dist/project-sync-transfer.js +404 -106
  45. package/dist/project-sync.js +30 -33
  46. package/dist/seed-phases.d.ts +29 -0
  47. package/dist/seed-phases.js +83 -0
  48. package/dist/t3-mode.d.ts +7 -2
  49. package/dist/t3-mode.js +39 -5
  50. package/dist/thread-on-computer.js +4 -0
  51. package/dist/worktree-twin.d.ts +2 -6
  52. package/dist/worktree-twin.js +16 -51
  53. package/package.json +5 -5
package/dist/move.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * `shard move` (0.19.0+): a Claude Code or Codex session on this computer continues in a cloud conversation as the same
3
+ * session, with its full history.
4
+ *
5
+ * 1. Waits until the session's current turn has ended (local-sessions.ts); a turn is never cut off.
6
+ * 2. Starts a conversation from the live twin's snapshot of the project (uncommitted edits included; the working
7
+ * tree, index and HEAD are not touched) whose record starts with the session (harness_session_id).
8
+ * 3. Puts the transcript, its paths rewritten to the VM's folder, at the harness's native path in the VM and as the
9
+ * conversation's durable copy in the project space, before the repository is seeded.
10
+ * 4. The VM's guest resumes it (`claude --resume <id>`, `codex resume <id>`); this command attaches or detaches.
11
+ * 5. Records the move here; the live twin brings the cloud work back to this checkout, and typing in the local copy
12
+ * shows one line saying it diverges (a Claude Code UserPromptSubmit hook; the local process is never stopped).
13
+ */
14
+ import { createHash } from 'node:crypto';
15
+ import { execFileSync } from 'node:child_process';
16
+ import { existsSync, realpathSync } from 'node:fs';
17
+ import { dirname, relative, resolve } from 'node:path';
18
+ import { setTimeout as delay } from 'node:timers/promises';
19
+ import { ShardfluxApiError } from '@shardflux/sdk';
20
+ import { out, str } from "./commands.js";
21
+ import { UsageError } from "./errors.js";
22
+ import { projectIdentity } from "./project-identity.js";
23
+ import { createConversation, conversationEnded, getConversation } from "./conversations.js";
24
+ import { syncAgentCredentials } from "./agent-credentials.js";
25
+ import { cloudClaudeLogin, loginClaudeInWorkspace, requireClaudeTerminal } from "./claude-cloud-login.js";
26
+ import { syncAgentConfig } from "./agent-config-sync.js";
27
+ import { savedMachine } from "./filesystem-state.js";
28
+ import { ensureMenubar, MenubarManager } from "./menubar.js";
29
+ import { conversationTerminal } from "./conversation-terminal.js";
30
+ import { openInChoices } from "./t3-mode.js";
31
+ import { openClaudeDesktopSession, remoteControlUrl } from "./agent-target-commands.js";
32
+ import { openUrl } from "./browser.js";
33
+ import { SYNC_CHUNK_BYTES } from "./project-sync-transfer.js";
34
+ import { completeRecords, continuedAfter, findLocalSession, homeRelativeTranscript, listLocalSessions, readTranscript, rewriteSessionPaths, sessionState } from "./local-sessions.js";
35
+ import { installDivergenceNotice, movedKey, movedSession, movedSessions, rememberMoved } from "./moved-sessions.js";
36
+ const VM_HOME = '/home/user';
37
+ const SPACE = '/shardflux/project';
38
+ /** A transcript above this moves with the repository's history bound. */
39
+ const TRANSCRIPT_LIMIT = 256 << 20;
40
+ // ---------------------------------------------------------------------------------------------------------------
41
+ // Choosing and waiting
42
+ /** The project a folder belongs to: its Git repository's top, or the folder itself. */
43
+ export function projectRoot(folder) {
44
+ try {
45
+ return realpathSync(execFileSync('git', ['-C', folder, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim());
46
+ }
47
+ catch {
48
+ return realpathSync(folder);
49
+ }
50
+ }
51
+ function harnessOf(values) {
52
+ if (values.claude === true && values.codex === true)
53
+ throw new UsageError('Choose --claude or --codex.');
54
+ return values.claude === true ? 'claude' : values.codex === true ? 'codex' : undefined;
55
+ }
56
+ export function chooseSession(values, env, folder) {
57
+ const id = str(values.session);
58
+ if (id) {
59
+ const s = findLocalSession(id.toLowerCase(), env);
60
+ if (!s)
61
+ throw new UsageError('No Claude Code or Codex session with that id on this computer.');
62
+ return s;
63
+ }
64
+ if (values.last !== true)
65
+ throw new UsageError('Choose --last or --session <id> (shard move --list shows this project’s sessions).');
66
+ const [s] = listLocalSessions(folder, env, { harness: harnessOf(values), limit: 1 });
67
+ if (!s)
68
+ throw new UsageError('This project has no Claude Code or Codex sessions on this computer.');
69
+ return s;
70
+ }
71
+ /** Until the session's turn has ended: a running turn is never cut off. */
72
+ export async function waitForTurnEnd(ctx, s, env, pollMs = 1000) {
73
+ let said = false;
74
+ for (;;) {
75
+ const state = sessionState(s, env);
76
+ if (state.turn === 'idle')
77
+ return state;
78
+ if (!said && !ctx.json) {
79
+ ctx.io.stderr.write('Waiting for the current turn to end…\n');
80
+ said = true;
81
+ }
82
+ await delay(pollMs, undefined, { signal: ctx.signal });
83
+ }
84
+ }
85
+ async function writeChunks(cell, path, bytes) {
86
+ if (!bytes.length) {
87
+ await cell.files.write(path, '', { mode: '0600' });
88
+ return;
89
+ }
90
+ for (let at = 0; at < bytes.length; at += SYNC_CHUNK_BYTES)
91
+ await cell.files.write(path, bytes.subarray(at, at + SYNC_CHUNK_BYTES), { mode: '0600', append: at > 0 });
92
+ }
93
+ /**
94
+ * The session's transcript in the conversation's VM: at its native path (where the harness resumes it) and, when the
95
+ * project space is mounted, as the conversation's durable copy with the same checkpoint metadata the guest writes
96
+ * (transcripts/<id>/meta.json), so the session survives the VM from its first minute.
97
+ */
98
+ export async function placeTranscript(cell, c, s, bytes, folder, signal) {
99
+ const sessionCwd = realOr(s.cwd), inside = relative(folder, sessionCwd);
100
+ if (inside.startsWith('..'))
101
+ throw new UsageError('The session ran outside this project folder; run shard move in its project.');
102
+ let moved = rewriteSessionPaths(bytes, folder, c.worktree_path);
103
+ if (s.cwd !== sessionCwd && !s.cwd.startsWith(`${folder}/`) && s.cwd !== folder) {
104
+ // The transcript recorded a path through a link (/tmp for /private/tmp): move that spelling too.
105
+ moved = rewriteSessionPaths(moved, s.cwd.slice(0, s.cwd.length - inside.length).replace(/\/$/, ''), c.worktree_path);
106
+ }
107
+ const homeRelative = homeRelativeTranscript(s, c.worktree_path);
108
+ const native = `${VM_HOME}/${homeRelative}`;
109
+ await cell.files.mkdir(dirname(native), { parents: true });
110
+ await writeChunks(cell, native, moved);
111
+ const mounted = await cell.exec.run(['test', '-d', `${SPACE}/git/hub.git`], { timeoutMs: 10_000, ...(signal ? { signal } : {}) });
112
+ if (mounted.exitCode !== 0)
113
+ return { native, durable: false };
114
+ const dir = `${SPACE}/transcripts/${c.id}`, file = homeRelative.split('/').at(-1);
115
+ await cell.files.mkdir(dir, { parents: true });
116
+ await writeChunks(cell, `${dir}/${file}`, moved);
117
+ const meta = { harness: c.harness, harness_session_id: s.session_id, file, home_relative: homeRelative, cwd: c.worktree_path, bytes: moved.length,
118
+ sha256: createHash('sha256').update(moved).digest('hex') };
119
+ await cell.files.write(`${dir}/meta.json`, JSON.stringify(meta), { mode: '0600' });
120
+ return { native, durable: true };
121
+ }
122
+ const realOr = (path) => { try {
123
+ return realpathSync(path);
124
+ }
125
+ catch {
126
+ return resolve(path);
127
+ } };
128
+ async function alreadyMoved(ctx, m) {
129
+ try {
130
+ const c = await getConversation(ctx, m.conversation_id);
131
+ return conversationEnded(c) ? undefined : c;
132
+ }
133
+ catch (e) {
134
+ if (e instanceof ShardfluxApiError && e.status === 404)
135
+ return undefined;
136
+ throw e;
137
+ }
138
+ }
139
+ export async function moveSession(ctx, values) {
140
+ const env = { ...process.env, ...ctx.io.env };
141
+ const here = projectRoot(typeof values.path === 'string' ? values.path : process.cwd());
142
+ const s = chooseSession(values, env, here);
143
+ const folder = typeof values.path === 'string' ? here : projectRoot(existsSync(s.cwd) ? s.cwd : here);
144
+ const prior = movedSession(ctx.store.dir, ctx.apiUrl, s.harness, s.session_id);
145
+ if (prior) {
146
+ const live = await alreadyMoved(ctx, prior);
147
+ if (live) {
148
+ out(ctx, `This session already continues in the cloud as ${prior.key}. Open it: shard agents open ${prior.key}\n`, { already: true, moved: prior, conversation: live });
149
+ return 0;
150
+ }
151
+ }
152
+ await waitForTurnEnd(ctx, s, env);
153
+ const bytes = completeRecords(readTranscript(s.file));
154
+ if (bytes.length > TRANSCRIPT_LIMIT)
155
+ throw new UsageError('This session’s transcript is above the 256 MiB move bound.');
156
+ const sha256 = createHash('sha256').update(bytes).digest('hex');
157
+ if (!ctx.json)
158
+ ctx.io.stderr.write(`Moving ${s.harness === 'claude' ? 'Claude Code' : 'Codex'} session “${s.title}” to the cloud…\n`);
159
+ await syncAgentCredentials(ctx.cloud(), { home: ctx.io.env.HOME, redact: value => ctx.addSecret(value) });
160
+ const signedIn = s.harness === 'claude' ? await cloudClaudeLogin(ctx) : true;
161
+ if (!signedIn)
162
+ requireClaudeTerminal(ctx);
163
+ let created;
164
+ let placed;
165
+ try {
166
+ created = await createConversation(ctx, folder, s.harness, 'terminal', undefined, {}, {
167
+ session_id: s.session_id, title: s.title,
168
+ // The workspace's cell handle is shared with the seed that follows: it stays open.
169
+ beforeSeed: async (ws, c) => { placed = await placeTranscript(ws.cell(), c, s, bytes, folder, ctx.signal); },
170
+ });
171
+ }
172
+ catch (e) {
173
+ if (e instanceof ShardfluxApiError && e.details?.reason === 'session_already_moved' && typeof e.details.conversation_id === 'string') {
174
+ const c = await getConversation(ctx, e.details.conversation_id);
175
+ out(ctx, `This session already continues in the cloud as ${c.key}. Open it: shard agents open ${c.key}\n`, { already: true, conversation: c });
176
+ return 0;
177
+ }
178
+ throw e;
179
+ }
180
+ const { conversation: c, workspace: ws } = created;
181
+ const moved = { api_url: ctx.apiUrl, harness: s.harness, session_id: s.session_id, folder, file: s.file, bytes: bytes.length, sha256,
182
+ conversation_id: c.id, key: c.key, title: s.title, moved_at: new Date().toISOString() };
183
+ rememberMoved(ctx.store.dir, moved);
184
+ try {
185
+ installDivergenceNotice(ctx.store.dir, env, moved);
186
+ }
187
+ catch (e) {
188
+ ctx.io.stderr.write(`The local divergence notice is unavailable: ${e instanceof Error ? e.message : String(e)}\n`);
189
+ }
190
+ if (s.harness === 'claude' && !signedIn)
191
+ await loginClaudeInWorkspace(ctx, ws);
192
+ if (!(await savedMachine(ctx))?.template)
193
+ await syncAgentConfig(ctx, ws);
194
+ // The live twin (the menu bar's device helper) brings the cloud work back to this checkout.
195
+ await ensureMenubar(ctx.store.dir, ctx.apiUrl, new MenubarManager({ home: ctx.io.env.HOME, signal: ctx.signal }));
196
+ const project = projectIdentity(folder), place = project.remote ?? project.folder;
197
+ const attach = !ctx.json && values.detach !== true && ctx.io.stdin.isTTY === true;
198
+ if (!attach) {
199
+ const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
200
+ await conversationTerminal(quiet, ws, c, place, { ...values, detach: true });
201
+ out(ctx, `Moved to the cloud as ${c.key}: the same session, with its history. Open it: shard agents open ${c.key}\n`, { moved, conversation: c, workspace: ws.data, ...(placed ? { transcript: placed } : {}) });
202
+ return 0;
203
+ }
204
+ const where = openInChoices(ctx, project.slug)[s.harness];
205
+ if (s.harness === 'claude' && (where === 'claude-desktop' || where === 'web')) {
206
+ const quiet = { ...ctx, io: { ...ctx.io, stdout: { write: () => true } } };
207
+ await conversationTerminal(quiet, ws, c, place, { ...values, detach: true });
208
+ if (where === 'claude-desktop')
209
+ return openClaudeDesktopSession(ctx, ws, c);
210
+ const url = await remoteControlUrl(ctx, ws, c);
211
+ if (!openUrl(url, ctx.io.env))
212
+ throw new Error(`could not open ${url}`);
213
+ out(ctx, `${url}\n`, { moved, conversation: c, url });
214
+ return 0;
215
+ }
216
+ ctx.io.stderr.write(`Moved to the cloud as ${c.key}: the same session, with its history.\n`);
217
+ return conversationTerminal(ctx, ws, c, place, values);
218
+ }
219
+ /** `shard move --list`: this project's sessions on this computer, and where each moved one continues. */
220
+ export function listMovable(ctx, values, env, now = Date.now()) {
221
+ const folder = projectRoot(typeof values.path === 'string' ? values.path : process.cwd());
222
+ const moved = movedSessions(ctx.store.dir, ctx.apiUrl);
223
+ const sessions = listLocalSessions(folder, env, { harness: harnessOf(values), limit: 10 }).map(s => {
224
+ const m = moved[movedKey(s.harness, s.session_id)];
225
+ const state = sessionState(s, env, now);
226
+ return { harness: s.harness, session_id: s.session_id, title: s.title, updated_at: s.updated_at, source: s.source, cwd: s.cwd,
227
+ running: state.running, turn: state.turn,
228
+ moved: m ? { conversation_id: m.conversation_id, key: m.key, moved_at: m.moved_at, diverged: continuedAfter(s, m.bytes) } : null };
229
+ });
230
+ return { folder, sessions };
231
+ }
232
+ const ago = (iso, now = Date.now()) => {
233
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
234
+ return s < 90 ? 'just now' : s < 5400 ? `${Math.round(s / 60)} min ago` : s < 129600 ? `${Math.round(s / 3600)} h ago` : `${Math.round(s / 86400)} d ago`;
235
+ };
236
+ export const MOVE_HANDLERS = {
237
+ async move(ctx, values) {
238
+ if (values.list === true) {
239
+ const env = { ...process.env, ...ctx.io.env };
240
+ const r = listMovable(ctx, values, env);
241
+ const lines = r.sessions.map(s => `${s.session_id} ${s.harness.padEnd(6)} ${s.title} · ${ago(s.updated_at)}${s.turn === 'working' ? ' · working' : ''}`
242
+ + (s.moved ? ` · in the cloud as ${s.moved.key}${s.moved.diverged ? ' (continued here too)' : ''}` : ''));
243
+ out(ctx, lines.length ? `${lines.join('\n')}\n` : `No Claude Code or Codex sessions in ${r.folder} on this computer.\n`, r);
244
+ return 0;
245
+ }
246
+ return moveSession(ctx, values);
247
+ },
248
+ };
@@ -0,0 +1,33 @@
1
+ import type { SessionHarness } from './local-sessions.js';
2
+ export interface MovedSession {
3
+ api_url: string;
4
+ harness: SessionHarness;
5
+ session_id: string;
6
+ folder: string;
7
+ /** The local transcript and how much of it moved (bytes, their SHA-256). */
8
+ file: string;
9
+ bytes: number;
10
+ sha256: string;
11
+ conversation_id: string;
12
+ key: string;
13
+ title: string;
14
+ moved_at: string;
15
+ }
16
+ type Env = Record<string, string | undefined>;
17
+ export declare const movedKey: (harness: SessionHarness, id: string) => string;
18
+ export declare function movedSessions(dir: string, apiUrl?: string): Record<string, MovedSession>;
19
+ export declare function movedSession(dir: string, apiUrl: string, harness: SessionHarness, id: string): MovedSession | undefined;
20
+ export declare function rememberMoved(dir: string, m: MovedSession): void;
21
+ /** Whether the local copy still holds exactly what moved (it may have grown by metadata, never by a turn). */
22
+ export declare function movedUnchanged(m: MovedSession): boolean;
23
+ export declare const NOTICE_SCRIPT = "#!/bin/sh\n# Shardflux (shard move): one line when you type in a session that continues in the cloud. Nothing else.\ninput=$(cat)\nid=\"${CLAUDE_CODE_SESSION_ID:-}\"\n[ -n \"$id\" ] || id=$(printf '%s' \"$input\" | sed -n 's/.*\"session_id\" *: *\"\\([0-9a-f-]*\\)\".*/\\1/p' | head -n 1)\nf=\"$(dirname \"$0\")/$id.json\"\n[ -n \"$id\" ] && [ -f \"$f\" ] && cat \"$f\"\nexit 0\n";
24
+ /** The hook command: silent and successful where the script is absent (a settings file copied into a VM). */
25
+ export declare const noticeCommand: (dir: string) => string;
26
+ export declare const noticeLine: (m: Pick<MovedSession, "key">) => string;
27
+ /**
28
+ * The one-line divergence notice: a marker per moved session, and (Claude Code) a UserPromptSubmit hook in the user's
29
+ * settings that prints the marker's systemMessage when one exists. Claude Code reloads its settings, so a session
30
+ * that is still open shows it on its next prompt. Codex asks people to review new hooks, so Codex sessions get none.
31
+ */
32
+ export declare function installDivergenceNotice(dir: string, env: Env, m: MovedSession): void;
33
+ export {};
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Sessions this computer moved to the cloud (`shard move`, 0.19.0+): the local record (agents.json "moved_sessions")
3
+ * and the one-line notice a moved session shows when someone types in its local copy.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { AgentStateStore } from "./agent-state.js";
9
+ import { claudeConfigDir, continuedAfter, readTranscript } from "./local-sessions.js";
10
+ export const movedKey = (harness, id) => `${harness}:${id}`;
11
+ export function movedSessions(dir, apiUrl) {
12
+ const all = (new AgentStateStore(dir).read().moved_sessions ?? {});
13
+ return Object.fromEntries(Object.entries(all).filter(([, m]) => !apiUrl || m.api_url === apiUrl));
14
+ }
15
+ export function movedSession(dir, apiUrl, harness, id) {
16
+ return movedSessions(dir, apiUrl)[movedKey(harness, id)];
17
+ }
18
+ export function rememberMoved(dir, m) {
19
+ new AgentStateStore(dir).update(doc => {
20
+ const all = (doc.moved_sessions ?? {});
21
+ all[movedKey(m.harness, m.session_id)] = m;
22
+ doc.moved_sessions = all;
23
+ });
24
+ }
25
+ /** Whether the local copy still holds exactly what moved (it may have grown by metadata, never by a turn). */
26
+ export function movedUnchanged(m) {
27
+ try {
28
+ const bytes = readTranscript(m.file);
29
+ return bytes.length >= m.bytes && createHash('sha256').update(bytes.subarray(0, m.bytes)).digest('hex') === m.sha256
30
+ && !continuedAfter({ harness: m.harness, file: m.file }, m.bytes);
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ export const NOTICE_SCRIPT = `#!/bin/sh
37
+ # Shardflux (shard move): one line when you type in a session that continues in the cloud. Nothing else.
38
+ input=$(cat)
39
+ id="\${CLAUDE_CODE_SESSION_ID:-}"
40
+ [ -n "$id" ] || id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\\([0-9a-f-]*\\)".*/\\1/p' | head -n 1)
41
+ f="$(dirname "$0")/$id.json"
42
+ [ -n "$id" ] && [ -f "$f" ] && cat "$f"
43
+ exit 0
44
+ `;
45
+ const noticeDir = (dir) => join(dir, 'moved');
46
+ /** The hook command: silent and successful where the script is absent (a settings file copied into a VM). */
47
+ export const noticeCommand = (dir) => {
48
+ const script = join(noticeDir(dir), 'notice.sh').replace(/'/g, `'\\''`);
49
+ return `f='${script}'; [ -f "$f" ] && /bin/sh "$f"; exit 0`;
50
+ };
51
+ export const noticeLine = (m) => `This session continues in the cloud as ${m.key}; typing here diverges from it. Open it there: shard agents open ${m.key}`;
52
+ /**
53
+ * The one-line divergence notice: a marker per moved session, and (Claude Code) a UserPromptSubmit hook in the user's
54
+ * settings that prints the marker's systemMessage when one exists. Claude Code reloads its settings, so a session
55
+ * that is still open shows it on its next prompt. Codex asks people to review new hooks, so Codex sessions get none.
56
+ */
57
+ export function installDivergenceNotice(dir, env, m) {
58
+ const markers = noticeDir(dir);
59
+ mkdirSync(markers, { recursive: true, mode: 0o700 });
60
+ writeAtomic(join(markers, 'notice.sh'), NOTICE_SCRIPT, 0o700);
61
+ writeAtomic(join(markers, `${m.session_id}.json`), JSON.stringify({ systemMessage: noticeLine(m) }) + '\n', 0o600);
62
+ if (m.harness !== 'claude')
63
+ return;
64
+ const file = join(claudeConfigDir(env), 'settings.json');
65
+ let settings = {};
66
+ if (existsSync(file)) {
67
+ try {
68
+ settings = JSON.parse(readFileSync(file, 'utf8'));
69
+ }
70
+ catch {
71
+ return;
72
+ } // A settings file someone is editing by hand is theirs; the notice is optional.
73
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings))
74
+ return;
75
+ }
76
+ const command = noticeCommand(dir);
77
+ const hooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) ? settings.hooks : {});
78
+ const groups = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
79
+ if (JSON.stringify(groups).includes(JSON.stringify(command).slice(1, -1)))
80
+ return;
81
+ hooks.UserPromptSubmit = [...groups, { hooks: [{ type: 'command', command, timeout: 5 }] }];
82
+ settings.hooks = hooks;
83
+ mkdirSync(dirname(file), { recursive: true });
84
+ writeAtomic(file, JSON.stringify(settings, null, 2) + '\n', 0o600);
85
+ }
86
+ function writeAtomic(file, text, mode) {
87
+ const tmp = `${file}.${process.pid}.tmp`;
88
+ writeFileSync(tmp, text, { mode });
89
+ chmodSync(tmp, mode);
90
+ renameSync(tmp, file);
91
+ }
@@ -48,15 +48,30 @@ export interface Connection {
48
48
  found?: boolean;
49
49
  /** T3 Code only: optional (never needed to continue), its app (for the icon) and how it connects. */
50
50
  optional?: boolean;
51
+ /** T3 Code over SSH, with --t3-connect: the T3 Connect sign-in just started (its page and code), as `t3_link` names it. */
52
+ t3_link?: T3SignInRow;
51
53
  app_path?: string;
52
54
  mode?: 'provider' | 'ssh';
53
55
  }
56
+ /** T3 Connect's one-time sign-in, as the card shows it: a page and a code, or no session here to start it with. */
57
+ export interface T3SignInRow {
58
+ status: 'needs_sign_in';
59
+ label: string;
60
+ sign_in?: {
61
+ verification_uri: string;
62
+ user_code: string;
63
+ expires_at?: string;
64
+ };
65
+ needs_login?: boolean;
66
+ }
54
67
  /**
55
68
  * T3 Code, when it is installed: an ACP-capable build (0.0.46+, nightly) has Shardflux as a provider, which every
56
- * `shard` run writes into T3's settings, so it is connected with nothing to sign in; stable 0.0.45 connects a project
57
- * over SSH (`shard agents new --in t3`), and says which environment to add. Null when T3 Code is not installed.
69
+ * `shard` run writes into T3's settings, so it is connected with nothing to sign in. Stable 0.0.45 connects with T3
70
+ * Connect, once per person: after that one sign-in every project set up for T3 Code (`shard agents new --in t3`)
71
+ * appears in T3 Code and the T3 app by itself. `startSignIn` (`--t3-connect`) starts that sign-in. Null when T3 Code
72
+ * is not installed.
58
73
  */
59
- export declare function t3Connection(ctx: Ctx, signedIn: boolean, appDirs?: string[], platform?: NodeJS.Platform): Promise<Connection | null>;
74
+ export declare function t3Connection(ctx: Ctx, signedIn: boolean, appDirs?: string[], platform?: NodeJS.Platform, startSignIn?: boolean): Promise<Connection | null>;
60
75
  /** Claude: the account login is ready and Claude itself accepts it right now. */
61
76
  export declare function claudeConnection(row: CredentialRow | undefined): Omit<Connection, 'id' | 'picked' | 'found'>;
62
77
  /** Codex: this Mac's Codex login is copied and the account's Codex remote device (one executor) is connected. */
@@ -64,10 +64,12 @@ export function parseApps(value) {
64
64
  }
65
65
  /**
66
66
  * T3 Code, when it is installed: an ACP-capable build (0.0.46+, nightly) has Shardflux as a provider, which every
67
- * `shard` run writes into T3's settings, so it is connected with nothing to sign in; stable 0.0.45 connects a project
68
- * over SSH (`shard agents new --in t3`), and says which environment to add. Null when T3 Code is not installed.
67
+ * `shard` run writes into T3's settings, so it is connected with nothing to sign in. Stable 0.0.45 connects with T3
68
+ * Connect, once per person: after that one sign-in every project set up for T3 Code (`shard agents new --in t3`)
69
+ * appears in T3 Code and the T3 app by itself. `startSignIn` (`--t3-connect`) starts that sign-in. Null when T3 Code
70
+ * is not installed.
69
71
  */
70
- export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.io.env), platform = process.platform) {
72
+ export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.io.env), platform = process.platform, startSignIn = false) {
71
73
  // The same install every T3 command uses (macOS and Linux): a build with ACP providers first.
72
74
  const app = t3Install(ctx.io.env, platform, appDirs);
73
75
  if (!app)
@@ -85,57 +87,81 @@ export async function t3Connection(ctx, signedIn, appDirs = applicationDirs(ctx.
85
87
  return { ...base, mode: 'provider', state: 'action', fact: error instanceof Error ? error.message : 'T3 Code’s settings could not be updated.', action: { title: 'Check again' } };
86
88
  }
87
89
  }
88
- // Stable T3: an SSH environment per project; the newest one this API set up is the one to add. They are recorded in
89
- // agents.json (targets.json keeps T3 out of its manifest); a manifest entry from an older CLI still counts.
90
+ // Stable T3: one T3 Connect sign-in per person (the API's, per account), then each project appears by itself. `next`
91
+ // is for scripts: a project is set up for T3 Code with it.
92
+ const ssh = { ...base, mode: 'ssh', next: shard(['agents', 'new', '--in', 't3', '--path', '<project>']) };
93
+ const offer = { ...ssh, state: 'action', fact: 'Your projects appear in T3 Code and the T3 app once connected.',
94
+ action: { title: 'Connect T3 Code', command: shard(['setup', '--inspect', '--t3-connect', '--json']) } };
95
+ if (!signedIn)
96
+ return offer;
97
+ const cloud = ctx.cloud();
98
+ let status;
99
+ try {
100
+ status = await cloud.t3.status();
101
+ }
102
+ catch {
103
+ return { ...ssh, state: 'unknown', fact: 'T3 Connect could not be checked just now.', action: { title: 'Retry', retry: true } };
104
+ }
105
+ // Remembered for `agents targets`: with the sign-in, every project can start in T3 Code (t3Status).
106
+ try {
107
+ new AgentStateStore(ctx.store.dir).saveT3Connected(ctx.apiUrl, status.connected);
108
+ }
109
+ catch { /* the next check writes it */ }
110
+ if (!status.connected) {
111
+ if (!startSignIn)
112
+ return offer;
113
+ const signIn = await startT3SignIn(ctx);
114
+ if (signIn === 'connected')
115
+ return t3Connection(ctx, signedIn, appDirs, platform, false);
116
+ if (signIn.needs_login)
117
+ return { ...offer, fact: 'Sign in to Shardflux on this computer to connect T3 Code.', t3_link: signIn };
118
+ return { ...offer, fact: 'Confirm the code on the page that opened. This updates on its own.', t3_link: signIn,
119
+ action: { title: 'Connect T3 Code', url: signIn.sign_in.verification_uri } };
120
+ }
121
+ const connected = { ...ssh, state: 'verified', fact: 'Your projects appear in T3 Code and the T3 app.' };
122
+ // The newest project set up for T3 Code from this computer (agents.json; a targets.json entry from an older CLI still
123
+ // counts): where it is. One set up before T3 Connect, or whose link failed, is linked now: it is the person's choice.
90
124
  const recorded = new AgentStateStore(ctx.store.dir).t3Environments(ctx.apiUrl).at(-1);
91
125
  const manifest = await jsonFile(join(ctx.store.dir, 'targets.json'));
92
126
  const entries = Array.isArray(manifest?.entries) ? manifest.entries : [];
93
- const legacy = entries.filter(e => e.api_url === ctx.apiUrl && Array.isArray(e.targets) && e.targets.includes('t3') && typeof e.host === 'string').at(-1);
94
- const host = recorded?.host ?? legacy?.host;
95
- const workspaceKey = recorded?.workspace ?? (typeof legacy?.workspace === 'string' ? legacy.workspace : undefined);
96
- if (host) {
97
- const desktop = `In T3 Code: Add environment → SSH → ${host}`;
98
- const phone = await t3Phone(ctx, workspaceKey);
99
- if (!phone)
100
- return { ...base, mode: 'ssh', state: 'verified', fact: desktop };
101
- return { ...base, mode: 'ssh', ...phone, fact: phone.state === 'verified' ? `${desktop}. On your phone: ${phone.fact}` : phone.fact };
102
- }
103
- // An SSH environment is per project (shardflux-<project>-main): it is set up once a project is picked.
104
- return { ...base, mode: 'ssh', state: 'info', fact: 'Available after you pick a project.', next: shard(['agents', 'new', '--in', 't3', '--path', '<project>']) };
105
- }
106
- /**
107
- * The T3 app on the phone (T3 Connect, 0.17.0+): the workspace is linked with the person's one T3 Connect sign-in. The
108
- * sign-in is a page to open once; afterwards every project appears in the T3 app by itself. Undefined when the API has
109
- * no T3 Connect: the desktop route is the whole card then.
110
- */
111
- async function t3Phone(ctx, key) {
127
+ const legacy = entries.filter(e => e.api_url === ctx.apiUrl && Array.isArray(e.targets) && e.targets.includes('t3') && typeof e.workspace === 'string').at(-1);
128
+ const key = recorded?.workspace ?? legacy?.workspace;
112
129
  if (!key)
113
- return undefined;
130
+ return connected;
114
131
  try {
115
132
  const ws = await resolveWorkspace(ctx, key);
116
- const cloud = ctx.cloud();
117
- const known = (await cloud.t3.status()).links.find(l => l.workspace_id === ws.id);
118
- // A workspace connected to T3 before T3 Connect, or one whose link failed, is linked now: it is the person's choice.
119
- const { link, signIn, needsLogin } = !known || known.status === 'error' || known.status === 'needs_sign_in'
120
- ? await t3PhoneLink(ctx, ws.id) : { link: known, signIn: undefined, needsLogin: false };
133
+ const known = status.links.find(l => l.workspace_id === ws.id);
134
+ const link = !known || known.status === 'error' || known.status === 'needs_sign_in' ? (await t3PhoneLink(ctx, ws.id)).link : known;
135
+ // Linked to the person's T3 account: T3 Code and the T3 app list it once they are signed in to that account.
121
136
  if (link.status === 'linked')
122
- return { state: 'verified', fact: `${link.label} in the T3 app.` };
123
- if (signIn) {
124
- return { state: 'action', fact: `To use it from the T3 app on your phone, confirm ${signIn.user_code} once.`,
125
- action: { title: 'Connect phone', url: signIn.verification_uri } };
126
- }
127
- if (needsLogin)
128
- return { state: 'info', fact: 'Sign in to Shardflux on this computer to use it from the T3 app.' };
129
- return { state: 'pending', fact: `Adding ${link.label} to the T3 app on your phone…` };
137
+ return { ...connected, fact: `${link.label} is linked to your T3 account.` };
138
+ if (link.status === 'linking')
139
+ return { ...ssh, state: 'pending', fact: `Linking ${link.label} to your T3 account…` };
140
+ return connected;
130
141
  }
131
142
  catch (error) {
132
- // No T3 Connect on this API, or nothing to ask it: the desktop route stands on its own.
133
- if (!(error instanceof ShardfluxApiError) || describeError(error).code === 'not_found')
134
- return undefined;
135
- // Another member's T3 Connect sign-in links this project: it is in their T3 app, not something to fix here.
143
+ // Another member's T3 Connect sign-in links this project: it is in their T3 Code, not something to fix here.
136
144
  if (error.details?.reason === 't3_linked_by_other_member')
137
- return { state: 'info', fact: 'In the T3 app of the member who linked it.' };
138
- return { state: 'unknown', fact: 'The T3 app link could not be checked just now.', action: { title: 'Retry', retry: true } };
145
+ return { ...connected, fact: 'In the T3 Code of the member who linked it.' };
146
+ // The project's VM is gone or unreachable: the account is connected, and the next project appears by itself.
147
+ return connected;
148
+ }
149
+ }
150
+ /**
151
+ * Starts the person's T3 Connect sign-in (a pending one comes back again). It needs this computer's Shardflux session,
152
+ * not only an API key. `connected` when the account already has one (409 `t3_signed_in`).
153
+ */
154
+ async function startT3SignIn(ctx) {
155
+ if (ctx.store.profile(ctx.apiUrl).session?.state !== 'active')
156
+ return { status: 'needs_sign_in', label: '', needs_login: true };
157
+ try {
158
+ const flow = await ctx.account().t3.signIn();
159
+ return { status: 'needs_sign_in', label: '', sign_in: { verification_uri: flow.verification_uri, user_code: flow.user_code, expires_at: flow.expires_at } };
160
+ }
161
+ catch (error) {
162
+ if (error instanceof ShardfluxApiError && describeError(error).code === 't3_signed_in')
163
+ return 'connected';
164
+ throw error;
139
165
  }
140
166
  }
141
167
  const shard = (words) => ['shard', ...words];
@@ -255,7 +281,7 @@ export async function onboardingReport(ctx, values) {
255
281
  const codex = { id: 'codex', picked: apps.includes('codex'), found: found.includes('codex'),
256
282
  ...(!connected ? signIn : (credentialError ?? codexError) !== undefined ? failure(credentialError ?? codexError)
257
283
  : codexConnection(localCodex, credentials.some(row => row.app === 'codex'), connector, health)) };
258
- const t3 = await t3Connection(ctx, connected);
284
+ const t3 = await t3Connection(ctx, connected, undefined, undefined, values['t3-connect'] === true);
259
285
  const connections = [account, claude, codex, ...(t3 ? [t3] : [])];
260
286
  // Ready: signed in and at least one agent app connected; the other can be connected later (Settings, or this again).
261
287
  const ready = account.state === 'verified' && [claude, codex].some(c => c.state === 'verified');
@@ -268,7 +294,7 @@ export async function onboardingReport(ctx, values) {
268
294
  { id: 'account', title: 'Sign in to Shardflux', status: connected ? 'complete' : 'available', command: shard(['signup']) },
269
295
  { id: 'connect', title: 'Connect Claude, Codex or both', status: ready ? 'complete' : 'available' },
270
296
  { id: 'demo', title: 'Try it once: your agent’s own computer', status: done(state?.demo_finished) ? 'complete' : 'available',
271
- action: 'Copy the task, press ⌥⇧Space, paste it, pick Daylight (sample) and start it. The agent installs what it needs, starts it and tests it in its own browser.' },
297
+ action: 'Copy the task and press ⌥⇧Space. Paste the task, then Start. The agent installs what it needs, starts it and tests it in its own browser.' },
272
298
  { id: 'own', title: 'Your own project', status: done(state?.own_key) ? 'complete' : 'available',
273
299
  action: 'Press ⌥⇧Space, pick your project and describe what you want.' },
274
300
  ],
@@ -1,6 +1,4 @@
1
1
  import type { FileManifest, FileManifestEntry } from '@shardflux/sdk';
2
- export declare const MAX_SYNC_FILE_BYTES: number;
3
- export declare const MAX_SYNC_ENTRIES = 20000;
4
2
  export type Manifest = Record<string, FileManifestEntry>;
5
3
  export type Common = Record<string, string>;
6
4
  export interface SyncChange {
@@ -13,9 +11,25 @@ export declare function excludedSyncPath(path: string): boolean;
13
11
  export declare function contentIdentity(entry?: FileManifestEntry): string;
14
12
  /** Deletions have an observation timestamp: a detached filesystem has no deletion clock. Ties favor the laptop. */
15
13
  export declare function diffManifests(local: Manifest, remote: Manifest, common: Common, observedAt?: number, blocked?: Set<string>): SyncChange[];
14
+ /**
15
+ * Validates a manifest's wire shape. Any number of entries and any file size are accepted; an entry whose path this
16
+ * client cannot represent is moved to `manifest.skipped` (reason `unsupported_path`) instead of failing the pass.
17
+ */
16
18
  export declare function parseManifest(manifest: FileManifest): Manifest;
17
19
  export declare function projectGit(folder: string, args: string[], signal?: AbortSignal, input?: string): Promise<string>;
18
- /** Uses the actual Git implementation rather than an approximation of gitignore. */
20
+ /**
21
+ * Streams Git's NUL-separated path output into `each`: no output buffer and no time bound, so a tree of any size is
22
+ * listed in full (the signal still cancels it). Exit codes in `ok` count as success (check-ignore exits 1 for none).
23
+ */
24
+ export declare function projectGitPaths(folder: string, args: string[], each: (path: string) => void, options?: {
25
+ signal?: AbortSignal;
26
+ input?: string;
27
+ ok?: number[];
28
+ }): Promise<void>;
29
+ /**
30
+ * Uses the actual Git implementation rather than an approximation of gitignore. Any number of paths; a path this
31
+ * client cannot represent stays in the list and is reported as skipped by `localEntry`.
32
+ */
19
33
  export declare function selectedProjectPaths(folder: string, signal?: AbortSignal): Promise<string[]>;
20
34
  export declare function ignoredProjectPaths(folder: string, paths: string[], signal?: AbortSignal): Promise<string[]>;
21
35
  /** Refuse parent symlinks, including a tracked file beneath a submodule/link. */
@@ -24,4 +38,18 @@ export type LocalHashCache = Map<string, {
24
38
  metadata: string;
25
39
  entry: FileManifestEntry;
26
40
  }>;
41
+ export type LocalEntry = FileManifestEntry | {
42
+ path: string;
43
+ reason: string;
44
+ } | undefined;
45
+ /**
46
+ * One project file's manifest entry: undefined when it does not exist (ENOENT), `{ path, reason }` when it is skipped
47
+ * (`unsupported_type`: a socket, device or FIFO; `unsupported_path`: a name the sync cannot carry, such as one with a
48
+ * backslash; `nested_repository`: a submodule or nested Git repository). Any size is hashed.
49
+ */
50
+ export declare function localEntry(folder: string, path: string, signal?: AbortSignal, cache?: LocalHashCache): Promise<FileManifestEntry | {
51
+ path: string;
52
+ reason: string;
53
+ } | undefined>;
54
+ /** The cache holds one entry per project path: entries for paths that left the project are dropped after each pass. */
27
55
  export declare function localManifest(folder: string, signal?: AbortSignal, cache?: LocalHashCache, known?: string[]): Promise<FileManifest>;