@pushary/agent-hooks 0.93.1 → 0.95.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 (61) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/data/cursor-plugin/CONTRIBUTING.md +31 -19
  3. package/data/vscode-plugin/CONTRIBUTING.md +31 -19
  4. package/dist/bin/pushary-bell-hook.js +22 -1
  5. package/dist/bin/pushary-bell.js +3 -3
  6. package/dist/bin/pushary-claude.js +335 -357
  7. package/dist/bin/pushary-clean.js +21 -16
  8. package/dist/bin/pushary-codex-bridge.js +6 -6
  9. package/dist/bin/pushary-codex-hook.js +20 -2
  10. package/dist/bin/pushary-codex.js +1 -1
  11. package/dist/bin/pushary-connect.js +3 -3
  12. package/dist/bin/pushary-daemon.js +33 -18
  13. package/dist/bin/pushary-disconnect.js +4 -2
  14. package/dist/bin/pushary-doctor.js +31 -28
  15. package/dist/bin/pushary-elicitation-hook.js +21 -1
  16. package/dist/bin/pushary-gemini-bridge.js +6 -6
  17. package/dist/bin/pushary-gemini-hook.js +20 -2
  18. package/dist/bin/pushary-hook.js +20 -3
  19. package/dist/bin/pushary-login.js +3 -3
  20. package/dist/bin/pushary-logout.js +3 -3
  21. package/dist/bin/pushary-mode.js +3 -3
  22. package/dist/bin/pushary-notification-hook.js +20 -2
  23. package/dist/bin/pushary-opencode-hook.js +20 -2
  24. package/dist/bin/pushary-permission-denied-hook.js +21 -3
  25. package/dist/bin/pushary-permission-hook.js +21 -3
  26. package/dist/bin/pushary-post-hook.js +20 -2
  27. package/dist/bin/pushary-prompt-hook.js +20 -2
  28. package/dist/bin/pushary-session-end-hook.js +24 -3
  29. package/dist/bin/pushary-session-start-hook.js +23 -8
  30. package/dist/bin/pushary-setup.js +47 -41
  31. package/dist/bin/pushary-status.js +3 -3
  32. package/dist/bin/pushary-stop-hook.js +20 -2
  33. package/dist/bin/pushary-stopfailure-hook.js +20 -2
  34. package/dist/bin/pushary-suggestions.js +3 -3
  35. package/dist/bin/pushary-transcript-register.d.ts +1 -0
  36. package/dist/bin/pushary-transcript-register.js +42 -0
  37. package/dist/bin/pushary-transcripts.js +1 -1
  38. package/dist/bin/pushary-upgrade.js +11 -10
  39. package/dist/bin/pushary-wait.js +3 -3
  40. package/dist/bin/pushary.js +10 -0
  41. package/dist/{chunk-6WYL3XIP.js → chunk-2WVDQVI5.js} +3 -2
  42. package/dist/{chunk-HSAVPZ46.js → chunk-5SJ54JJO.js} +1 -1
  43. package/dist/{chunk-QKNLW7H6.js → chunk-6LQAGVG2.js} +1 -1
  44. package/dist/{chunk-OB4RF2TF.js → chunk-AYJ7NH5S.js} +1 -1
  45. package/dist/chunk-BMOFXRGS.js +185 -0
  46. package/dist/chunk-FH3YLP5Z.js +194 -0
  47. package/dist/chunk-GHCGSEPE.js +86 -0
  48. package/dist/chunk-GI5UQSG7.js +266 -0
  49. package/dist/{chunk-ZO2XG3CX.js → chunk-LSXJH2HB.js} +1 -1
  50. package/dist/{chunk-YANV5TGK.js → chunk-MDQOBG5J.js} +1 -84
  51. package/dist/chunk-N6FAXQDD.js +276 -0
  52. package/dist/{chunk-5HGLZGI4.js → chunk-OXTRFRMD.js} +1 -0
  53. package/dist/chunk-PQ4K74WL.js +30 -0
  54. package/dist/{chunk-46P3VES5.js → chunk-Q5UFC2QK.js} +1 -0
  55. package/dist/{chunk-36LR2LOT.js → chunk-SFCGKE6U.js} +8 -6
  56. package/dist/{chunk-6CUUA7HO.js → chunk-XY4GSZ3K.js} +7 -1
  57. package/dist/chunk-YGZXYEVY.js +503 -0
  58. package/dist/src/index.js +5 -5
  59. package/package.json +4 -3
  60. package/dist/chunk-FYOIW5RV.js +0 -539
  61. package/dist/chunk-RX75MRGC.js +0 -368
@@ -1,47 +1,52 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- changedRecipientsMessage,
4
- loadPin,
5
- savePin,
6
- verifyRecipients
7
- } from "../chunk-YKLCWWEI.js";
8
- import {
9
- ensureDaemonRunning
10
- } from "../chunk-ZTJF6ANY.js";
11
- import {
12
- buildTranscriptRecords,
13
- decodePublicKey,
14
- generateSessionKey,
15
- wrapSessionKey
16
- } from "../chunk-46P3VES5.js";
17
- import {
18
- fetchTranscriptRecipients,
19
- uploadTranscriptRecords
20
- } from "../chunk-EQRS3SGM.js";
21
- import {
22
- transcriptsEnabled
23
- } from "../chunk-6WYL3XIP.js";
3
+ WRAPPER_ACTIVE_ENV,
4
+ findClaudeBinary
5
+ } from "../chunk-PQ4K74WL.js";
24
6
  import {
25
7
  SESSION_IDLE_TIMEOUT_MS,
26
8
  describeIdleWindow,
27
9
  startProcessOwnership,
28
10
  withSpawnProcessOwnership
29
11
  } from "../chunk-TDUTNYAQ.js";
12
+ import {
13
+ isDeferAnswer
14
+ } from "../chunk-YHG74UFF.js";
15
+ import {
16
+ claudeTranscriptPath,
17
+ loadSessionKey,
18
+ registerTranscript,
19
+ registeredTranscriptEnabled
20
+ } from "../chunk-YGZXYEVY.js";
21
+ import "../chunk-YKLCWWEI.js";
22
+ import "../chunk-GI5UQSG7.js";
23
+ import "../chunk-GHCGSEPE.js";
24
+ import "../chunk-5SJ54JJO.js";
25
+ import {
26
+ ensureDaemonRunning
27
+ } from "../chunk-ZTJF6ANY.js";
28
+ import "../chunk-BMOFXRGS.js";
29
+ import "../chunk-DSTLA2TC.js";
30
+ import "../chunk-EZIEXHYM.js";
31
+ import {
32
+ encryptRecord
33
+ } from "../chunk-Q5UFC2QK.js";
34
+ import "../chunk-EQRS3SGM.js";
35
+ import {
36
+ transcriptsEnabled
37
+ } from "../chunk-2WVDQVI5.js";
30
38
  import {
31
39
  needsShell,
32
40
  spawnClaude
33
41
  } from "../chunk-XHKBHWLX.js";
34
42
  import "../chunk-L4ZCFMWW.js";
35
- import {
36
- isDeferAnswer
37
- } from "../chunk-YHG74UFF.js";
38
43
  import {
39
44
  fetchModeState,
40
45
  getPolicy,
41
46
  phoneDrivenEnv,
42
47
  repoKeyFor,
43
48
  reportEvent
44
- } from "../chunk-5HGLZGI4.js";
49
+ } from "../chunk-OXTRFRMD.js";
45
50
  import {
46
51
  DEFAULT_SESSION,
47
52
  askUser,
@@ -65,6 +70,7 @@ import {
65
70
  getMachineId
66
71
  } from "../chunk-NVPYRDCT.js";
67
72
  import "../chunk-Y6XXFXVB.js";
73
+ import "../chunk-GMXKITVA.js";
68
74
  import "../chunk-DINDL2CF.js";
69
75
  import "../chunk-6BIMJIRG.js";
70
76
  import "../chunk-BC3VCZ3E.js";
@@ -80,35 +86,10 @@ import {
80
86
  } from "../chunk-2UMNXADU.js";
81
87
  import {
82
88
  KILL_REASON,
89
+ redactSecrets,
83
90
  resolveGate
84
91
  } from "../chunk-Z6CP5ZZD.js";
85
92
 
86
- // src/wrapper/claudeBinary.ts
87
- import { statSync } from "fs";
88
- import { join, delimiter } from "path";
89
- var WRAPPER_ACTIVE_ENV = "PUSHARY_WRAPPER_ACTIVE";
90
- var isFile = (path) => {
91
- try {
92
- return statSync(path).isFile();
93
- } catch {
94
- return false;
95
- }
96
- };
97
- var findClaudeBinary = () => {
98
- const override = process.env.PUSHARY_CLAUDE_BIN?.trim();
99
- if (override) return isFile(override) ? override : null;
100
- const pathVar = process.env.PATH ?? "";
101
- const exts = process.platform === "win32" ? [".cmd", ".exe", ".bat", ""] : [""];
102
- for (const dir of pathVar.split(delimiter)) {
103
- if (!dir) continue;
104
- for (const ext of exts) {
105
- const candidate = join(dir, `claude${ext}`);
106
- if (isFile(candidate)) return candidate;
107
- }
108
- }
109
- return null;
110
- };
111
-
112
93
  // src/wrapper/localPassthrough.ts
113
94
  var SIGNAL_NUMBERS = {
114
95
  SIGHUP: 1,
@@ -119,7 +100,7 @@ var SIGNAL_NUMBERS = {
119
100
  };
120
101
  var FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
121
102
  var runLocalPassthrough = (binary, args2) => {
122
- return new Promise((resolve2) => {
103
+ return new Promise((resolve) => {
123
104
  let child;
124
105
  try {
125
106
  child = spawnClaude(binary, args2, {
@@ -127,7 +108,7 @@ var runLocalPassthrough = (binary, args2) => {
127
108
  env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" }
128
109
  });
129
110
  } catch {
130
- resolve2(127);
111
+ resolve(127);
131
112
  return;
132
113
  }
133
114
  const forward = (signal) => {
@@ -142,275 +123,126 @@ var runLocalPassthrough = (binary, args2) => {
142
123
  };
143
124
  child.on("error", () => {
144
125
  cleanup();
145
- resolve2(127);
126
+ resolve(127);
146
127
  });
147
128
  child.on("exit", (code2, signal) => {
148
129
  cleanup();
149
- if (typeof code2 === "number") resolve2(code2);
150
- else if (signal) resolve2(128 + (SIGNAL_NUMBERS[signal] ?? 0));
151
- else resolve2(0);
130
+ if (typeof code2 === "number") resolve(code2);
131
+ else if (signal) resolve(128 + (SIGNAL_NUMBERS[signal] ?? 0));
132
+ else resolve(0);
152
133
  });
153
134
  });
154
135
  };
155
136
 
156
137
  // src/wrapper/dualMode.ts
157
- import { basename } from "path";
138
+ import { existsSync as existsSync2 } from "fs";
158
139
 
159
- // src/transcript-sync.ts
160
- import { homedir as homedir2 } from "os";
161
- import { join as join3, resolve } from "path";
162
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
163
-
164
- // src/transcript-session-key.ts
165
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync as statSync2, writeFileSync } from "fs";
166
- import { homedir } from "os";
167
- import { join as join2 } from "path";
168
- var sessionDir = () => process.env.PUSHARY_SESSION_KEY_DIR?.trim() || join2(homedir(), ".pushary", "sessions");
169
- var SESSION_KEY_TTL_MS = 48 * 60 * 60 * 1e3;
170
- var SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
171
- var fileFor = (sessionId) => SAFE_SESSION_ID.test(sessionId) ? join2(sessionDir(), `${sessionId}.json`) : null;
172
- var loadSessionKey = (sessionId) => {
173
- const path = fileFor(sessionId);
174
- if (!path || !existsSync(path)) return null;
175
- try {
176
- if (Date.now() - statSync2(path).mtimeMs > SESSION_KEY_TTL_MS) return null;
177
- const raw = JSON.parse(readFileSync(path, "utf-8"));
178
- const key = typeof raw.sessionKey === "string" ? Buffer.from(raw.sessionKey, "base64") : null;
179
- if (!key || key.length !== 32) return null;
180
- return {
181
- sessionKey: new Uint8Array(key),
182
- wrappedKey: typeof raw.wrappedKey === "string" ? raw.wrappedKey : null,
183
- providerWrappedKey: typeof raw.providerWrappedKey === "string" ? raw.providerWrappedKey : null,
184
- providerKeyId: typeof raw.providerKeyId === "string" ? raw.providerKeyId : null,
185
- keyUploaded: raw.keyUploaded === true
186
- };
187
- } catch {
188
- return null;
140
+ // src/transcript-draft.ts
141
+ var redactDraft = (text, complete = false) => {
142
+ let end = complete ? text.length : text.lastIndexOf("\n") + 1;
143
+ const pem = /-----BEGIN [^\n]*PRIVATE KEY-----/g;
144
+ for (const match of text.matchAll(pem)) {
145
+ if (!/-----END [^\n]*PRIVATE KEY-----/.test(text.slice(match.index, end))) end = Math.min(end, match.index);
189
146
  }
190
- };
191
- var saveSessionKey = (sessionId, value) => {
192
- const path = fileFor(sessionId);
193
- if (!path) return;
194
- try {
195
- mkdirSync(sessionDir(), { recursive: true, mode: 448 });
196
- writeFileSync(
197
- path,
198
- JSON.stringify({
199
- sessionKey: Buffer.from(value.sessionKey).toString("base64"),
200
- wrappedKey: value.wrappedKey,
201
- providerWrappedKey: value.providerWrappedKey,
202
- providerKeyId: value.providerKeyId,
203
- keyUploaded: value.keyUploaded
204
- }),
205
- { mode: 384 }
206
- );
207
- } catch {
147
+ const quoted = /(?:secret|token|password|passwd|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)\s*[=:]\s*(["'])/gi;
148
+ for (const match of text.matchAll(quoted)) {
149
+ const close = text.indexOf(match[1], match.index + match[0].length);
150
+ if (close < 0 || close >= end) end = Math.min(end, match.index);
208
151
  }
152
+ return redactSecrets(text.slice(0, end));
209
153
  };
210
- var sweepStaleSessionKeys = (now = Date.now()) => {
211
- const dir = sessionDir();
212
- if (!existsSync(dir)) return 0;
213
- let removed = 0;
214
- try {
215
- for (const name of readdirSync(dir)) {
216
- if (!name.endsWith(".json")) continue;
217
- const path = join2(dir, name);
218
- try {
219
- if (now - statSync2(path).mtimeMs <= SESSION_KEY_TTL_MS) continue;
220
- rmSync(path, { force: true });
221
- removed += 1;
222
- } catch {
154
+ var DraftText = class {
155
+ messageId = "";
156
+ text = "";
157
+ complete = false;
158
+ interrupted = false;
159
+ add(message) {
160
+ if (message.parent_tool_use_id) return;
161
+ const event = message.event;
162
+ if (message.type === "stream_event" && event?.type === "message_start") {
163
+ this.messageId = event.message?.id ?? "";
164
+ this.text = "";
165
+ this.complete = false;
166
+ this.interrupted = false;
167
+ } else if (message.type === "stream_event" && event?.type === "content_block_delta" && event.delta?.type === "text_delta") {
168
+ this.text = (this.text + (event.delta.text ?? "")).slice(0, 32 * 1024);
169
+ } else if (message.type === "assistant") {
170
+ const body = message.message;
171
+ if (body?.id === this.messageId && Array.isArray(body.content)) {
172
+ this.text = body.content.filter((block) => block.type === "text").map((block) => block.text).join("").slice(0, 32 * 1024);
173
+ this.complete = true;
223
174
  }
224
175
  }
225
- } catch {
226
- return removed;
227
176
  }
228
- return removed;
229
- };
230
-
231
- // src/transcript-sync.ts
232
- var claudeTranscriptPath = (cwd, sessionId) => {
233
- const projectId = resolve(cwd).replace(/[^a-zA-Z0-9-]/g, "-");
234
- const configDir = process.env.CLAUDE_CONFIG_DIR || join3(homedir2(), ".claude");
235
- return join3(configDir, "projects", projectId, `${sessionId}.jsonl`);
236
- };
237
- var createSyncState = () => ({
238
- sessionKey: null,
239
- wrappedKey: null,
240
- providerWrappedKey: null,
241
- providerKeyId: null,
242
- seen: /* @__PURE__ */ new Set(),
243
- keyUploaded: false,
244
- keyRejected: false,
245
- keyUnavailable: false
246
- });
247
- var syncTick = async (lines, state, uploader, sessionId, machineId) => {
248
- if (state.keyRejected) return state;
249
- let sessionKey = state.sessionKey;
250
- let wrappedKey = state.wrappedKey;
251
- let providerWrappedKey = state.providerWrappedKey;
252
- let providerKeyId = state.providerKeyId;
253
- if (!sessionKey) {
254
- const recipients = await uploader.fetchTranscriptRecipients();
255
- if (!recipients) return state;
256
- sessionKey = generateSessionKey();
257
- wrappedKey = wrapSessionKey(sessionKey, decodePublicKey(recipients.phonePublicKey));
258
- if (recipients.provider) {
259
- providerWrappedKey = wrapSessionKey(sessionKey, decodePublicKey(recipients.provider.publicKey));
260
- providerKeyId = recipients.provider.keyId;
261
- }
262
- }
263
- const built = buildTranscriptRecords(lines, sessionKey, state.seen);
264
- if (built.records.length === 0) {
265
- return { ...state, sessionKey, wrappedKey, providerWrappedKey, providerKeyId, seen: built.seen };
266
- }
267
- const seen = new Set(state.seen);
268
- let keyUploaded = state.keyUploaded;
269
- let keyRejected = false;
270
- let keyUnavailable = false;
271
- let rejected = 0;
272
- for (const chunk of chunkRecords(built.records)) {
273
- const result = await uploader.uploadRecords({
274
- sessionId,
275
- machineId,
276
- dataEncryptionKey: keyUploaded ? void 0 : wrappedKey ?? void 0,
277
- providerWrappedSessionKey: keyUploaded ? void 0 : providerWrappedKey ?? void 0,
278
- providerKeyId: keyUploaded ? void 0 : providerKeyId ?? void 0,
279
- records: chunk
280
- });
281
- keyUploaded = keyUploaded || result.keyRegistered;
282
- if (result.keyConflict) {
283
- keyRejected = true;
284
- break;
285
- }
286
- if (result.keyUnavailable) {
287
- keyUnavailable = true;
288
- break;
289
- }
290
- if (!result.ok) break;
291
- for (const id of result.settledIds ?? chunk.map((r) => r.localId)) seen.add(id);
292
- rejected += result.rejectedIds.length;
293
- }
294
- if (rejected > 0) {
295
- process.stderr.write(`[pushary] transcript: ${rejected} record${rejected === 1 ? "" : "s"} could not be stored and will not be retried.
296
- `);
177
+ interrupt() {
178
+ if (!this.complete) this.interrupted = true;
297
179
  }
298
- return {
299
- sessionKey,
300
- wrappedKey,
301
- providerWrappedKey,
302
- providerKeyId,
303
- seen,
304
- keyUploaded,
305
- keyRejected,
306
- keyUnavailable
307
- };
308
- };
309
- var MAX_RECORDS_PER_BATCH = 200;
310
- var MAX_BATCH_BYTES = 3e6;
311
- var chunkRecords = (records) => {
312
- const chunks = [];
313
- let current = [];
314
- let bytes = 0;
315
- for (const record of records) {
316
- const size = record.content.length;
317
- if (current.length > 0 && (current.length >= MAX_RECORDS_PER_BATCH || bytes + size > MAX_BATCH_BYTES)) {
318
- chunks.push(current);
319
- current = [];
320
- bytes = 0;
321
- }
322
- current.push(record);
323
- bytes += size;
180
+ snapshot() {
181
+ const text = redactDraft(this.text, this.complete);
182
+ return this.messageId && text ? { messageId: this.messageId, text, status: this.interrupted ? "interrupted" : this.complete ? "complete" : "streaming" } : null;
324
183
  }
325
- if (current.length > 0) chunks.push(current);
326
- return chunks;
327
184
  };
328
- var readLines = (path) => {
329
- try {
330
- return existsSync2(path) ? readFileSync2(path, "utf-8").split("\n") : [];
331
- } catch {
332
- return [];
333
- }
334
- };
335
- var IDLE_CEILING_MS = 6e4;
336
- var nextInterval = (previousMs, idle, baseMs, maxMs) => idle ? Math.min(previousMs * 2, maxMs) : baseMs;
337
- var startTranscriptSync = (opts) => {
338
- let warnedAboutRecipients = false;
339
- const fetchPinnedRecipients = async () => {
340
- const offer = await fetchTranscriptRecipients(opts.apiKey);
341
- if (!offer) return null;
342
- const verdict = verifyRecipients(offer, loadPin());
343
- if (verdict.kind === "first-use" && !savePin(verdict.pin)) return null;
344
- if (verdict.kind === "changed") {
345
- if (!warnedAboutRecipients) {
346
- warnedAboutRecipients = true;
347
- process.stderr.write(`${changedRecipientsMessage(verdict, offer)}
348
- `);
349
- }
350
- return null;
351
- }
352
- return offer;
353
- };
354
- const uploader = {
355
- fetchTranscriptRecipients: fetchPinnedRecipients,
356
- uploadRecords: (params) => uploadTranscriptRecords(opts.apiKey, params)
357
- };
358
- const path = claudeTranscriptPath(opts.cwd, opts.sessionId);
359
- const persisted = loadSessionKey(opts.sessionId);
360
- let state = persisted ? { ...createSyncState(), ...persisted } : createSyncState();
361
- sweepStaleSessionKeys();
362
- let persistedUploaded = persisted?.keyUploaded ?? false;
363
- let running = true;
364
- let inFlight = false;
365
- const tick = async () => {
366
- if (!running || inFlight) return;
367
- inFlight = true;
368
- try {
369
- const before = state.sessionKey;
370
- state = await syncTick(readLines(path), state, uploader, opts.sessionId, opts.machineId);
371
- if (state.sessionKey && (before !== state.sessionKey || state.keyUploaded !== persistedUploaded)) {
372
- persistedUploaded = state.keyUploaded;
373
- saveSessionKey(opts.sessionId, {
374
- sessionKey: state.sessionKey,
375
- wrappedKey: state.wrappedKey,
376
- providerWrappedKey: state.providerWrappedKey,
377
- providerKeyId: state.providerKeyId,
378
- keyUploaded: state.keyUploaded
185
+ var startTranscriptDraft = (apiKey, getSessionId) => {
186
+ const text = new DraftText();
187
+ const startedAt = Date.now();
188
+ let revision = 0;
189
+ let stopped = false;
190
+ let pending;
191
+ let ending = false;
192
+ let sent = "";
193
+ const flush = async () => {
194
+ if (stopped || pending || !transcriptsEnabled()) return;
195
+ const sessionId = getSessionId();
196
+ const snapshot = text.snapshot();
197
+ if (!sessionId || !snapshot || !registeredTranscriptEnabled(apiKey, sessionId)) return;
198
+ const raw = JSON.stringify(snapshot);
199
+ if (raw === sent) return;
200
+ const key = loadSessionKey(sessionId);
201
+ if (!key?.keyUploaded) return;
202
+ pending = (async () => {
203
+ try {
204
+ const response = await fetch(`${getBaseUrl()}/api/agent/transcript`, {
205
+ method: "POST",
206
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
207
+ body: JSON.stringify({ sessionId, draft: { startedAt, revision: ++revision, content: encryptRecord(raw, key.sessionKey) } }),
208
+ signal: AbortSignal.timeout(5e3)
379
209
  });
210
+ if (response.ok) sent = raw;
211
+ } catch {
380
212
  }
381
- } catch {
213
+ })();
214
+ try {
215
+ await pending;
382
216
  } finally {
383
- inFlight = false;
217
+ pending = void 0;
384
218
  }
385
219
  };
386
- const baseIntervalMs = opts.intervalMs ?? 3e3;
387
- const maxIntervalMs = Math.max(baseIntervalMs, IDLE_CEILING_MS);
388
- let timer;
389
- const schedule = (delayMs) => {
390
- if (!running) return;
391
- timer = setTimeout(() => {
392
- void (async () => {
393
- const before = state.sessionKey;
394
- await tick();
395
- const idle = state.sessionKey === null && before === null || state.keyUnavailable;
396
- schedule(nextInterval(delayMs, idle, baseIntervalMs, maxIntervalMs));
397
- })();
398
- }, delayMs);
399
- if (typeof timer.unref === "function") timer.unref();
400
- };
401
- void tick();
402
- schedule(baseIntervalMs);
403
- return () => {
404
- running = false;
405
- if (timer) clearTimeout(timer);
220
+ const timer = setInterval(() => void flush(), 800);
221
+ timer.unref();
222
+ return {
223
+ add: (message) => {
224
+ if (!ending) text.add(message);
225
+ },
226
+ stop: async () => {
227
+ if (ending) return;
228
+ ending = true;
229
+ clearInterval(timer);
230
+ text.interrupt();
231
+ await pending;
232
+ await flush();
233
+ stopped = true;
234
+ }
406
235
  };
407
236
  };
408
237
 
238
+ // src/wrapper/dualMode.ts
239
+ import { basename } from "path";
240
+
409
241
  // src/wrapper/sdkLoader.ts
410
242
  import { spawn } from "child_process";
411
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
412
- import { homedir as homedir3 } from "os";
413
- import { join as join4 } from "path";
243
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
244
+ import { homedir } from "os";
245
+ import { join } from "path";
414
246
  import { pathToFileURL } from "url";
415
247
  var userMessage = (text) => ({
416
248
  type: "user",
@@ -420,8 +252,8 @@ var userMessage = (text) => ({
420
252
  var SDK_VERSION = "0.3.207";
421
253
  var SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
422
254
  var SDK_SPEC = `${SDK_PACKAGE}@${SDK_VERSION}`;
423
- var sdkCacheDir = () => process.env.PUSHARY_REMOTE_SDK_DIR?.trim() || join4(homedir3(), ".pushary", "remote-sdk");
424
- var cachedSdkEntry = () => join4(sdkCacheDir(), "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
255
+ var sdkCacheDir = () => process.env.PUSHARY_REMOTE_SDK_DIR?.trim() || join(homedir(), ".pushary", "remote-sdk");
256
+ var cachedSdkEntry = () => join(sdkCacheDir(), "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
425
257
  var importSdk = async (specifierOrUrl) => {
426
258
  try {
427
259
  const mod = await import(specifierOrUrl);
@@ -436,7 +268,7 @@ var loadClaudeSdk = async () => {
436
268
  const fromResolution = await importSdk(SDK_PACKAGE);
437
269
  if (fromResolution) return fromResolution;
438
270
  const entry = cachedSdkEntry();
439
- if (existsSync3(entry)) return importSdk(pathToFileURL(entry).href);
271
+ if (existsSync(entry)) return importSdk(pathToFileURL(entry).href);
440
272
  return null;
441
273
  };
442
274
  var ensureClaudeSdk = async (log) => {
@@ -444,10 +276,10 @@ var ensureClaudeSdk = async (log) => {
444
276
  if (present) return present;
445
277
  const dir = sdkCacheDir();
446
278
  try {
447
- mkdirSync2(dir, { recursive: true });
448
- const pkgJson = join4(dir, "package.json");
449
- if (!existsSync3(pkgJson)) {
450
- writeFileSync2(pkgJson, `${JSON.stringify({ name: "pushary-remote-sdk", private: true })}
279
+ mkdirSync(dir, { recursive: true });
280
+ const pkgJson = join(dir, "package.json");
281
+ if (!existsSync(pkgJson)) {
282
+ writeFileSync(pkgJson, `${JSON.stringify({ name: "pushary-remote-sdk", private: true })}
451
283
  `);
452
284
  }
453
285
  } catch {
@@ -455,14 +287,14 @@ var ensureClaudeSdk = async (log) => {
455
287
  }
456
288
  log?.("[pushary] setting up phone control (one-time, fetching the Claude Agent SDK)...");
457
289
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
458
- const installed = await new Promise((resolve2) => {
290
+ const installed = await new Promise((resolve) => {
459
291
  const child = spawn(
460
292
  npmCmd,
461
293
  ["install", "--no-optional", "--no-audit", "--no-fund", "--loglevel=error", SDK_SPEC],
462
294
  { cwd: dir, stdio: "ignore", shell: process.platform === "win32" }
463
295
  );
464
- child.on("error", () => resolve2(false));
465
- child.on("close", (code2) => resolve2(code2 === 0));
296
+ child.on("error", () => resolve(false));
297
+ child.on("close", (code2) => resolve(code2 === 0));
466
298
  });
467
299
  if (!installed) {
468
300
  log?.(`[pushary] could not auto-install the SDK. Install it manually: npm i -g ${SDK_SPEC}`);
@@ -476,6 +308,7 @@ var BoundedInputQueue = class {
476
308
  buffer = [];
477
309
  waiter;
478
310
  closed = false;
311
+ generation = 0;
479
312
  cap;
480
313
  onDrop;
481
314
  coalesce;
@@ -491,18 +324,18 @@ var BoundedInputQueue = class {
491
324
  if (this.closed) return false;
492
325
  this.pushedCount++;
493
326
  if (this.waiter) {
494
- const resolve2 = this.waiter;
327
+ const resolve = this.waiter;
495
328
  this.waiter = void 0;
496
329
  this.deliveredCount++;
497
- resolve2({ value: item, done: false });
330
+ resolve({ value: item, done: false });
498
331
  return true;
499
332
  }
500
- this.buffer.push(item);
501
- while (this.buffer.length > this.cap) {
502
- const dropped = this.buffer.shift();
333
+ if (this.buffer.length >= this.cap) {
503
334
  this.droppedCount++;
504
- this.onDrop?.(dropped, this.droppedCount);
335
+ this.onDrop?.(item, this.droppedCount);
336
+ return false;
505
337
  }
338
+ this.buffer.push(item);
506
339
  return true;
507
340
  }
508
341
  // Mark done. Any parked consumer completes; further push() is ignored. The
@@ -512,9 +345,9 @@ var BoundedInputQueue = class {
512
345
  this.closed = true;
513
346
  this.buffer.length = 0;
514
347
  if (this.waiter) {
515
- const resolve2 = this.waiter;
348
+ const resolve = this.waiter;
516
349
  this.waiter = void 0;
517
- resolve2({ value: void 0, done: true });
350
+ resolve({ value: void 0, done: true });
518
351
  }
519
352
  }
520
353
  // End the CURRENT consumer without closing the queue, so a fresh query can
@@ -522,10 +355,11 @@ var BoundedInputQueue = class {
522
355
  // the parked waiter as done lets the abandoned query's for-await unwind cleanly
523
356
  // instead of hanging on a promise that would otherwise never settle.
524
357
  detachConsumer() {
358
+ this.generation++;
525
359
  if (this.waiter) {
526
- const resolve2 = this.waiter;
360
+ const resolve = this.waiter;
527
361
  this.waiter = void 0;
528
- resolve2({ value: void 0, done: true });
362
+ resolve({ value: void 0, done: true });
529
363
  }
530
364
  }
531
365
  // Drop every buffered item without closing the queue. Used when phone control
@@ -548,8 +382,12 @@ var BoundedInputQueue = class {
548
382
  return { pushed: this.pushedCount, delivered: this.deliveredCount, dropped: this.droppedCount };
549
383
  }
550
384
  [Symbol.asyncIterator]() {
385
+ const generation = this.generation;
551
386
  return {
552
387
  next: () => {
388
+ if (generation !== this.generation || this.closed) {
389
+ return Promise.resolve({ value: void 0, done: true });
390
+ }
553
391
  if (this.buffer.length > 0) {
554
392
  if (this.coalesce && this.buffer.length > 1) {
555
393
  const items = this.buffer.splice(0, this.buffer.length);
@@ -568,12 +406,12 @@ var BoundedInputQueue = class {
568
406
  new Error("BoundedInputQueue: concurrent consumers are not supported")
569
407
  );
570
408
  }
571
- return new Promise((resolve2) => {
572
- this.waiter = resolve2;
409
+ return new Promise((resolve) => {
410
+ this.waiter = resolve;
573
411
  });
574
412
  },
575
413
  return: () => {
576
- this.close();
414
+ if (generation === this.generation) this.detachConsumer();
577
415
  return Promise.resolve({ value: void 0, done: true });
578
416
  }
579
417
  };
@@ -610,8 +448,8 @@ var createRemoteApprover = (deps) => {
610
448
  const deadline = now() + windowMs;
611
449
  while (now() < deadline && !entry.controller.signal.aborted) {
612
450
  const remaining = Math.min(Math.max(deadline - now(), 1e3), 3e4);
613
- const abortPromise = new Promise((resolve2) => {
614
- entry.controller.signal.addEventListener("abort", () => resolve2({ answered: false, aborted: true }), {
451
+ const abortPromise = new Promise((resolve) => {
452
+ entry.controller.signal.addEventListener("abort", () => resolve({ answered: false, aborted: true }), {
615
453
  once: true
616
454
  });
617
455
  });
@@ -842,7 +680,7 @@ var startCommandPoller = (opts) => {
842
680
  const text = typeof command === "string" ? command : command.text;
843
681
  let accepted = false;
844
682
  try {
845
- accepted = opts.onCommand(text) !== false;
683
+ accepted = await opts.onCommand(text) !== false;
846
684
  } catch {
847
685
  }
848
686
  if (typeof command !== "string") {
@@ -878,7 +716,7 @@ var startCommandPoller = (opts) => {
878
716
 
879
717
  // src/wrapper/pollClient.ts
880
718
  var APPROVAL_MODES = /* @__PURE__ */ new Set(["push_only", "terminal_only", "push_first", "notify_only"]);
881
- var pollAgent = async (apiKey, sessionId, wantCommand, signal, baseUrl = getBaseUrl(), machineId) => {
719
+ var pollAgent = async (apiKey, sessionId, wantCommand, signal, baseUrl = getBaseUrl(), machineId, controlMode, takeoverDeclined) => {
882
720
  try {
883
721
  const sessionKey = sessionId || DEFAULT_SESSION;
884
722
  const receiptNamespace = `${apiKey}:${machineId}:claude_code`;
@@ -921,6 +759,8 @@ var pollAgent = async (apiKey, sessionId, wantCommand, signal, baseUrl = getBase
921
759
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
922
760
  body: JSON.stringify({
923
761
  ...sessionId ? { sessionId } : {},
762
+ ...controlMode ? { controlMode } : {},
763
+ ...takeoverDeclined ? { takeoverDeclined } : {},
924
764
  ...machineId ? { machineId } : {},
925
765
  ...wantCommand && !storedCommand ? {} : { drain: false },
926
766
  ...wantCommand && !storedCommand && machineId ? { ackMode: true } : {}
@@ -956,7 +796,7 @@ var pollAgent = async (apiKey, sessionId, wantCommand, signal, baseUrl = getBase
956
796
  policyVersion: typeof data.policyVersion === "string" && data.policyVersion.length > 0 ? data.policyVersion : null,
957
797
  relayUrl: typeof data.relayUrl === "string" && data.relayUrl.length > 0 ? data.relayUrl : null
958
798
  };
959
- return { command, mode, rejected: false };
799
+ return { command, mode, rejected: false, ...data.takeoverRequested === true ? { takeoverRequested: true } : {} };
960
800
  } catch {
961
801
  return { command: null, mode: null, rejected: false };
962
802
  }
@@ -1034,6 +874,7 @@ var createRelayClient = (options) => {
1034
874
  let pongTimer;
1035
875
  const seenCommands = [];
1036
876
  const seenSet = /* @__PURE__ */ new Set();
877
+ const pendingCommands = /* @__PURE__ */ new Map();
1037
878
  const log = (message) => options.log?.(message);
1038
879
  const receiptNamespace = `${options.apiKey}:${options.machineId}:${options.agentType}`;
1039
880
  const acknowledgeStored = (id) => {
@@ -1093,7 +934,7 @@ var createRelayClient = (options) => {
1093
934
  armPong();
1094
935
  }, heartbeatMs);
1095
936
  };
1096
- const onServerFrame = (raw) => {
937
+ const onServerFrame = async (raw) => {
1097
938
  const frame = decodeServerFrame(raw);
1098
939
  if (!frame) return;
1099
940
  switch (frame.t) {
@@ -1102,6 +943,11 @@ var createRelayClient = (options) => {
1102
943
  log("[pushary] relay connected");
1103
944
  break;
1104
945
  case "command":
946
+ if (pendingCommands.has(frame.id)) {
947
+ await pendingCommands.get(frame.id);
948
+ acknowledgeStored(frame.id);
949
+ break;
950
+ }
1105
951
  if (seenSet.has(frame.id)) {
1106
952
  sendFrame({ v: PROTOCOL_VERSION, t: "ack", id: frame.id, state: "delivered" });
1107
953
  break;
@@ -1118,9 +964,14 @@ var createRelayClient = (options) => {
1118
964
  acknowledgeStored(frame.id);
1119
965
  break;
1120
966
  }
967
+ let completePending;
968
+ pendingCommands.set(frame.id, new Promise((resolve) => {
969
+ completePending = resolve;
970
+ }));
1121
971
  let accepted = false;
1122
972
  try {
1123
- accepted = options.onCommand(frame.text) !== false;
973
+ const result = options.onCommand(frame.text);
974
+ accepted = (result instanceof Promise ? await result : result) !== false;
1124
975
  } catch {
1125
976
  }
1126
977
  if (accepted && markPendingCommandDelivered(sessionKey, frame.id, receiptNamespace)) {
@@ -1135,6 +986,8 @@ var createRelayClient = (options) => {
1135
986
  failureCode: "consumer_rejected"
1136
987
  });
1137
988
  }
989
+ completePending();
990
+ pendingCommands.delete(frame.id);
1138
991
  break;
1139
992
  case "ping":
1140
993
  sendFrame({ v: PROTOCOL_VERSION, t: "pong" });
@@ -1299,14 +1152,22 @@ var flagValue = (args2, index) => {
1299
1152
  const next = args2[index + 1];
1300
1153
  return next && !next.startsWith("-") ? next : void 0;
1301
1154
  };
1302
- var PASS_THROUGH_VALUE_FLAGS = /* @__PURE__ */ new Set(["--model", "-m", "--cwd", "--add-dir", "--settings"]);
1155
+ var PASS_THROUGH_VALUE_FLAGS = /* @__PURE__ */ new Set(["--cwd", "--add-dir"]);
1303
1156
  var parseRemoteArgs = (args2) => {
1304
1157
  let initialPrompt;
1305
1158
  let resume;
1306
1159
  let permissionMode;
1160
+ let model;
1161
+ let settings;
1307
1162
  for (let i = 0; i < args2.length; i++) {
1308
1163
  const arg = args2[i];
1309
- if (PASS_THROUGH_VALUE_FLAGS.has(arg ?? "")) {
1164
+ if (arg === "--model" || arg === "-m" || arg?.startsWith("--model=")) {
1165
+ model = flagValue(args2, i);
1166
+ if (!arg.includes("=")) i++;
1167
+ } else if (arg === "--settings" || arg?.startsWith("--settings=")) {
1168
+ settings = flagValue(args2, i);
1169
+ if (!arg.includes("=")) i++;
1170
+ } else if (PASS_THROUGH_VALUE_FLAGS.has(arg ?? "")) {
1310
1171
  i++;
1311
1172
  } else if (arg === "-p" || arg === "--print" || arg?.startsWith("--print=")) {
1312
1173
  initialPrompt = flagValue(args2, i);
@@ -1316,15 +1177,38 @@ var parseRemoteArgs = (args2) => {
1316
1177
  permissionMode = flagValue(args2, i);
1317
1178
  }
1318
1179
  }
1319
- return { initialPrompt, resume, permissionMode };
1180
+ return { initialPrompt, resume, permissionMode, ...model ? { model } : {}, ...settings ? { settings } : {} };
1181
+ };
1182
+ var remoteCompatibilityIssue = (args2) => {
1183
+ const values = /* @__PURE__ */ new Set(["--model", "-m", "--settings", "--permission-mode", "--resume", "-r", "--session-id", "-p", "--print", "--cwd", "--add-dir"]);
1184
+ const switches = /* @__PURE__ */ new Set(["--continue", "-c", "--verbose", "--debug", "--dangerously-skip-permissions"]);
1185
+ for (let index = 0; index < args2.length; index++) {
1186
+ const arg = args2[index];
1187
+ if (!arg.startsWith("-")) continue;
1188
+ const flag = arg.split("=")[0];
1189
+ if (switches.has(flag)) continue;
1190
+ if (!values.has(flag)) return `Phone takeover does not yet preserve ${flag}; keep using this terminal.`;
1191
+ if (!arg.includes("=") && args2[index + 1] && !args2[index + 1].startsWith("-")) index++;
1192
+ }
1193
+ return null;
1320
1194
  };
1321
1195
 
1322
1196
  // src/wrapper/localLeg.ts
1197
+ import { readFileSync } from "fs";
1198
+ import { homedir as homedir2 } from "os";
1199
+ import { join as join2 } from "path";
1323
1200
  var SIGNAL_EXIT_BASE = 128;
1324
1201
  var SIGNAL_NUMBERS2 = { SIGHUP: 1, SIGINT: 2, SIGTERM: 15 };
1325
1202
  var nativeArgs = (session) => {
1326
- const args2 = [...session.claudeArgs];
1203
+ let args2 = [...session.claudeArgs];
1327
1204
  const id = session.getSessionId();
1205
+ if (id && session.userManagesSession && session.isSessionCreated()) {
1206
+ args2 = args2.filter((arg, index, all) => {
1207
+ if (/^(--resume|-r|--session-id)(=|$)|^(--continue|-c)$/.test(arg)) return false;
1208
+ return !/^(--resume|-r|--session-id)$/.test(all[index - 1] ?? "") || arg.startsWith("-");
1209
+ });
1210
+ return [...args2, "--resume", id];
1211
+ }
1328
1212
  if (id && !session.userManagesSession) {
1329
1213
  args2.push(session.isSessionCreated() ? "--resume" : "--session-id", id);
1330
1214
  }
@@ -1332,22 +1216,24 @@ var nativeArgs = (session) => {
1332
1216
  };
1333
1217
  var runLocalLeg = (session, deps = {}) => {
1334
1218
  const spawn2 = deps.spawn ?? spawnClaude;
1335
- return new Promise((resolve2) => {
1219
+ return new Promise((resolve) => {
1336
1220
  const legAbort = new AbortController();
1337
1221
  let exitReason = null;
1338
1222
  let settled = false;
1223
+ let identityTimer;
1339
1224
  const finish = () => {
1340
1225
  if (settled) return;
1341
1226
  settled = true;
1227
+ if (identityTimer) clearInterval(identityTimer);
1342
1228
  session.setActiveLeg(null);
1343
- resolve2(exitReason ?? { type: "exit", code: 0 });
1229
+ resolve(exitReason ?? { type: "exit", code: 0 });
1344
1230
  };
1345
1231
  const control = {
1346
1232
  onPhoneCommand: () => {
1347
1233
  if (!exitReason) exitReason = "switch";
1348
1234
  if (!legAbort.signal.aborted) {
1349
1235
  process.stderr.write(
1350
- "\n[pushary] instruction from your phone \u2014 taking this session over to run it (Ctrl-] hands the terminal back)\n"
1236
+ "\n[pushary] phone takeover requested \u2014 resuming this conversation with phone control (Ctrl-] hands the terminal back)\n"
1351
1237
  );
1352
1238
  legAbort.abort();
1353
1239
  }
@@ -1376,8 +1262,24 @@ var runLocalLeg = (session, deps = {}) => {
1376
1262
  finish();
1377
1263
  return;
1378
1264
  }
1265
+ const spawnedAt = Date.now();
1266
+ const readIdentity = () => {
1267
+ if (!child.pid) return;
1268
+ try {
1269
+ const root = process.env.CLAUDE_CONFIG_DIR || join2(homedir2(), ".claude");
1270
+ const record = JSON.parse(readFileSync(join2(root, "sessions", `${child.pid}.json`), "utf8"));
1271
+ if (record.pid !== child.pid || record.cwd !== process.cwd() || record.startedAt < spawnedAt - 5e3 || typeof record.startedAt !== "number" || !/^[a-f0-9-]{36}$/i.test(record.sessionId)) return;
1272
+ if (record.sessionId !== session.getSessionId()) session.setSessionId(record.sessionId);
1273
+ session.markSessionCreated();
1274
+ } catch {
1275
+ }
1276
+ };
1379
1277
  session.markSessionCreated();
1380
- child.on("error", () => {
1278
+ readIdentity();
1279
+ identityTimer = setInterval(readIdentity, 500);
1280
+ identityTimer.unref();
1281
+ child.on("error", (error) => {
1282
+ if (legAbort.signal.aborted && error.name === "AbortError") return;
1381
1283
  if (!legAbort.signal.aborted && !exitReason) {
1382
1284
  exitReason = { type: "exit", code: 127 };
1383
1285
  }
@@ -1435,7 +1337,10 @@ var runRemoteLoop = async (deps) => {
1435
1337
  prompt: deps.input,
1436
1338
  options: {
1437
1339
  resume: resumeId,
1340
+ includePartialMessages: true,
1438
1341
  permissionMode: deps.permissionMode,
1342
+ model: deps.model,
1343
+ settings: deps.settings,
1439
1344
  canUseTool: deps.canUseTool,
1440
1345
  env: deps.env,
1441
1346
  cwd: deps.cwd,
@@ -1447,8 +1352,9 @@ var runRemoteLoop = async (deps) => {
1447
1352
  return ended(1, "failed");
1448
1353
  }
1449
1354
  const onAbort = () => {
1450
- deps.input.close();
1451
- void query.interrupt?.().catch(() => {
1355
+ deps.input.detachConsumer();
1356
+ if (query.close) query.close();
1357
+ else void query.interrupt?.().catch(() => {
1452
1358
  });
1453
1359
  };
1454
1360
  if (deps.signal.aborted) onAbort();
@@ -1471,6 +1377,14 @@ var runRemoteLoop = async (deps) => {
1471
1377
  for await (const message of query) {
1472
1378
  deps.onMessage?.(message);
1473
1379
  if (isInit(message) && typeof message.session_id === "string") {
1380
+ if (resumeId && message.session_id !== resumeId) {
1381
+ disarmIdle();
1382
+ deps.signal.removeEventListener("abort", onAbort);
1383
+ deps.input.detachConsumer();
1384
+ query.close?.();
1385
+ deps.log?.("[pushary] refused a different session ID during resume");
1386
+ return ended(1, "failed");
1387
+ }
1474
1388
  resumeId = message.session_id;
1475
1389
  deps.onSessionId?.(resumeId);
1476
1390
  }
@@ -1484,11 +1398,13 @@ var runRemoteLoop = async (deps) => {
1484
1398
  }
1485
1399
  disarmIdle();
1486
1400
  deps.signal.removeEventListener("abort", onAbort);
1401
+ query.close?.();
1487
1402
  if (idleExpired) return ended(0, "idle");
1488
1403
  return deps.signal.aborted ? ended(130, "aborted") : ended(0, "input-closed");
1489
1404
  } catch (err) {
1490
1405
  disarmIdle();
1491
1406
  deps.signal.removeEventListener("abort", onAbort);
1407
+ query.close?.();
1492
1408
  if (idleExpired) return ended(0, "idle");
1493
1409
  if (deps.signal.aborted) return ended(130, "aborted");
1494
1410
  restarts++;
@@ -1511,6 +1427,7 @@ var CTRL_C = "";
1511
1427
  var terminalAttended = () => Boolean(process.stdin.isTTY);
1512
1428
  var runRemoteLeg = async (session, sdk, deps = {}) => {
1513
1429
  const legAbort = new AbortController();
1430
+ const launchArgs = parseRemoteArgs(session.claudeArgs);
1514
1431
  const attended = deps.attended ?? terminalAttended();
1515
1432
  let switchBack = false;
1516
1433
  let idleEnded = false;
@@ -1546,6 +1463,8 @@ var runRemoteLeg = async (session, sdk, deps = {}) => {
1546
1463
  // conversation is continuous across the switch. Undefined = fresh session.
1547
1464
  initialResume: session.getSessionId(),
1548
1465
  permissionMode: session.permissionMode,
1466
+ model: launchArgs.model,
1467
+ settings: launchArgs.settings,
1549
1468
  // The SDK REPLACES the subprocess env; spread process.env and mark the
1550
1469
  // recursion guard so a wrapper-spawned claude cannot re-enter the wrapper.
1551
1470
  env: phoneDrivenEnv({ ...process.env, [WRAPPER_ACTIVE_ENV]: "1" }),
@@ -1596,7 +1515,6 @@ var driveModeLoop = async (deps) => {
1596
1515
  // src/wrapper/dualMode.ts
1597
1516
  var INPUT_QUEUE_CAP = 32;
1598
1517
  var MIN_MODE_DWELL_MS = 300;
1599
- var RELAY_FALLBACK_POLL_MS = 6e4;
1600
1518
  var PRESENCE_HEARTBEAT_MS = 6e4;
1601
1519
  var SESSION_FLAG_RE = /^(-r|--resume|-c|--continue|--session-id)(=.*)?$/;
1602
1520
  var extractAssistantText = (message) => {
@@ -1609,6 +1527,12 @@ var extractAssistantText = (message) => {
1609
1527
  return void 0;
1610
1528
  };
1611
1529
  var runDualMode = async (binary, args2, startingMode) => {
1530
+ const compatibilityIssue = remoteCompatibilityIssue(args2);
1531
+ if (startingMode === "remote" && compatibilityIssue) {
1532
+ process.stderr.write(`[pushary] ${compatibilityIssue}
1533
+ `);
1534
+ return { implemented: false };
1535
+ }
1612
1536
  let apiKey;
1613
1537
  try {
1614
1538
  apiKey = getApiKey();
@@ -1619,12 +1543,14 @@ var runDualMode = async (binary, args2, startingMode) => {
1619
1543
  return { implemented: false };
1620
1544
  }
1621
1545
  let sdk;
1546
+ let sdkLoading;
1622
1547
  let remoteAvailable = true;
1623
1548
  let noticedUnavailable = false;
1624
1549
  const ensureSdk = async () => {
1625
1550
  if (sdk === void 0) {
1626
- sdk = await ensureClaudeSdk((message) => process.stderr.write(`${message}
1551
+ sdkLoading ??= ensureClaudeSdk((message) => process.stderr.write(`${message}
1627
1552
  `));
1553
+ sdk = await sdkLoading;
1628
1554
  if (!sdk) remoteAvailable = false;
1629
1555
  }
1630
1556
  return sdk;
@@ -1644,7 +1570,9 @@ var runDualMode = async (binary, args2, startingMode) => {
1644
1570
  };
1645
1571
  const { initialPrompt, resume, permissionMode } = parseRemoteArgs(args2);
1646
1572
  const userManagesSession = args2.some((a) => SESSION_FLAG_RE.test(a));
1647
- let sessionId = resume;
1573
+ const explicitIdIndex = args2.findIndex((arg) => arg === "--session-id" || arg.startsWith("--session-id="));
1574
+ const explicitId = explicitIdIndex < 0 ? void 0 : args2[explicitIdIndex].split("=")[1] ?? args2[explicitIdIndex + 1];
1575
+ let sessionId = resume ?? explicitId;
1648
1576
  let sessionCreated = Boolean(resume);
1649
1577
  if (!sessionId && startingMode === "local" && !userManagesSession) {
1650
1578
  sessionId = crypto.randomUUID();
@@ -1653,9 +1581,12 @@ var runDualMode = async (binary, args2, startingMode) => {
1653
1581
  let killed = false;
1654
1582
  let finished = false;
1655
1583
  let activeLeg = null;
1584
+ let controlMode = startingMode;
1585
+ let takeoverPending = false;
1586
+ let takeoverDecline;
1656
1587
  const input = new BoundedInputQueue({
1657
1588
  cap: INPUT_QUEUE_CAP,
1658
- onDrop: (_dropped, total) => process.stderr.write(`[pushary] input queue full; dropped ${total} stale instruction(s)
1589
+ onDrop: (_dropped, total) => process.stderr.write(`[pushary] input queue full; refused ${total} instruction(s)
1659
1590
  `),
1660
1591
  coalesce: (items) => userMessage(items.map((m) => m.message.content).join("\n\n"))
1661
1592
  });
@@ -1667,9 +1598,9 @@ var runDualMode = async (binary, args2, startingMode) => {
1667
1598
  getSessionId: () => sessionId
1668
1599
  });
1669
1600
  const initialModeState = await fetchModeState(apiKey).catch(() => null);
1670
- const relayAdvertised = Boolean(initialModeState?.relayUrl);
1671
1601
  let relay;
1672
- if (initialModeState?.relayUrl) {
1602
+ const startRelay = () => {
1603
+ if (!initialModeState?.relayUrl || relay) return;
1673
1604
  relay = createRelayClient({
1674
1605
  url: initialModeState.relayUrl,
1675
1606
  apiKey,
@@ -1681,7 +1612,7 @@ var runDualMode = async (binary, args2, startingMode) => {
1681
1612
  log: verboseLog
1682
1613
  });
1683
1614
  relay.start();
1684
- }
1615
+ };
1685
1616
  let ownership;
1686
1617
  let ownershipSessionId;
1687
1618
  const ensureOwnership = async () => {
@@ -1707,13 +1638,12 @@ var runDualMode = async (binary, args2, startingMode) => {
1707
1638
  } catch {
1708
1639
  }
1709
1640
  };
1710
- let stopTranscript;
1711
1641
  let presenceHeartbeat;
1712
- let transcriptStarted = false;
1642
+ let transcriptStarted;
1713
1643
  const ensureTranscriptSync = () => {
1714
- if (transcriptStarted || !transcriptsEnabled() || !sessionId) return;
1715
- transcriptStarted = true;
1716
- stopTranscript = startTranscriptSync({ apiKey, sessionId, cwd: process.cwd(), machineId });
1644
+ if (transcriptStarted === sessionId || !transcriptsEnabled() || !sessionId) return;
1645
+ transcriptStarted = sessionId;
1646
+ registerTranscript("claude", { session_id: sessionId, cwd: process.cwd() });
1717
1647
  };
1718
1648
  const ensurePresenceHeartbeat = () => {
1719
1649
  if (presenceHeartbeat || !sessionId) return;
@@ -1732,19 +1662,22 @@ var runDualMode = async (binary, args2, startingMode) => {
1732
1662
  ensurePresenceHeartbeat();
1733
1663
  ensureTranscriptSync();
1734
1664
  };
1735
- const onPhoneCommand = (command) => {
1736
- if (!remoteAvailable) {
1665
+ const onPhoneCommand = async (command) => {
1666
+ if (controlMode !== "remote") return false;
1667
+ if (!sessionId || finished || !await ensureSdk() || !remoteAvailable) {
1737
1668
  if (!noticedUnavailable) {
1738
1669
  noticedUnavailable = true;
1739
1670
  process.stderr.write("[pushary] phone control is unavailable this run; the instruction was not delivered.\n");
1740
1671
  }
1741
1672
  return false;
1742
1673
  }
1743
- if (!input.push(userMessage(command))) return false;
1674
+ if (finished || !input.push(userMessage(command))) return false;
1744
1675
  activeLeg?.onPhoneCommand();
1745
1676
  return true;
1746
1677
  };
1678
+ let draft;
1747
1679
  const streamMessage = (message) => {
1680
+ draft?.add(message);
1748
1681
  if (message.type === "assistant") {
1749
1682
  const text = extractAssistantText(message);
1750
1683
  if (text) process.stdout.write(`${text}
@@ -1755,18 +1688,52 @@ var runDualMode = async (binary, args2, startingMode) => {
1755
1688
  void announcePresence("stop", "Turn complete");
1756
1689
  }
1757
1690
  };
1691
+ const declineTakeover = (reason, message) => {
1692
+ takeoverDecline = reason;
1693
+ process.stderr.write(`[pushary] ${message}
1694
+ `);
1695
+ };
1696
+ const takeOver = async () => {
1697
+ if (controlMode !== "local" || takeoverPending || finished) return;
1698
+ if (compatibilityIssue) {
1699
+ declineTakeover("unsupported_flags", compatibilityIssue);
1700
+ return;
1701
+ }
1702
+ takeoverPending = true;
1703
+ try {
1704
+ if (!sessionId || !existsSync2(claudeTranscriptPath(process.cwd(), sessionId))) {
1705
+ declineTakeover("no_transcript", "phone takeover needs a turn in this session first; your terminal and queued instructions are unchanged.");
1706
+ return;
1707
+ }
1708
+ if (!await ensureSdk()) {
1709
+ declineTakeover("sdk_unavailable", "phone takeover could not start the Agent SDK; your terminal and queued instructions are unchanged.");
1710
+ return;
1711
+ }
1712
+ if (!finished && controlMode === "local") activeLeg?.onPhoneCommand();
1713
+ } finally {
1714
+ takeoverPending = false;
1715
+ }
1716
+ };
1758
1717
  const poller = startCommandPoller({
1759
1718
  apiKey,
1760
1719
  getSessionId: () => sessionId,
1761
1720
  onCommand: (command) => onPhoneCommand(command),
1762
- shouldDrainCommands: () => !(relay?.connected() ?? false),
1721
+ shouldDrainCommands: () => controlMode === "remote" && Boolean(sessionId) && !(relay?.connected() ?? false),
1763
1722
  // Unified poll: one request per tick for both command + mode/kill (half the
1764
1723
  // requests and the per-poll DB write of the old drain + mode pair).
1765
- poll: (key, id, wantCommand, signal) => pollAgent(key, id, wantCommand, signal, void 0, machineId),
1724
+ poll: async (key, id, wantCommand, signal) => {
1725
+ const declined = takeoverDecline;
1726
+ takeoverDecline = void 0;
1727
+ const result = await pollAgent(key, id, wantCommand, signal, void 0, machineId, controlMode, declined);
1728
+ if (result.takeoverRequested) void takeOver();
1729
+ return result;
1730
+ },
1766
1731
  fetchModeState,
1767
- // Relay-primary: idle the safety-net poll at 60s (see RELAY_FALLBACK_POLL_MS).
1768
- // No relay advertised -> the poller IS the command path, so keep the default.
1769
- slowPollMs: relayAdvertised ? RELAY_FALLBACK_POLL_MS : void 0,
1732
+ // Observe explicit takeover promptly even when the command queue is idle.
1733
+ // Not faster than this: the poll runs for the life of every wrapper, including
1734
+ // local mode where it cannot return a command, and each tick costs a handful
1735
+ // of Redis round trips. Takeover already sits behind a confirm and a biometric.
1736
+ slowPollMs: 15e3,
1770
1737
  onAuthRejected: () => {
1771
1738
  process.stderr.write(
1772
1739
  "[pushary] this key was rejected \u2014 remote stop and phone messages are NOT active; run `pushary login` to reconnect\n"
@@ -1817,7 +1784,7 @@ var runDualMode = async (binary, args2, startingMode) => {
1817
1784
  try {
1818
1785
  if (sessionId && startingMode === "local") {
1819
1786
  process.stderr.write(
1820
- "[pushary] reachable from your phone \u2014 send an instruction from the app when this goes idle (Ctrl-] keeps the terminal).\n"
1787
+ "[pushary] phone messages queue for the next turn. Choose Continue on phone in the app for idle control (Ctrl-] returns the terminal).\n"
1821
1788
  );
1822
1789
  void announcePresence("session_start", "Session ready \u2014 reachable from your phone");
1823
1790
  ensurePresenceHeartbeat();
@@ -1827,8 +1794,17 @@ var runDualMode = async (binary, args2, startingMode) => {
1827
1794
  if (startingMode === "remote" && initialPrompt) input.push(userMessage(initialPrompt));
1828
1795
  const exitCode = await driveModeLoop({
1829
1796
  startingMode,
1830
- runLocal: () => runLocalLeg(session),
1797
+ runLocal: async () => {
1798
+ controlMode = "local";
1799
+ await draft?.stop();
1800
+ draft = void 0;
1801
+ relay?.stop();
1802
+ relay = void 0;
1803
+ return runLocalLeg(session);
1804
+ },
1831
1805
  runRemote: async () => {
1806
+ controlMode = "remote";
1807
+ startRelay();
1832
1808
  const ready = await ensureSdk();
1833
1809
  if (!ready) {
1834
1810
  remoteAvailable = false;
@@ -1847,6 +1823,7 @@ var runDualMode = async (binary, args2, startingMode) => {
1847
1823
  }
1848
1824
  return "switch";
1849
1825
  }
1826
+ draft = startTranscriptDraft(apiKey, () => sessionId);
1850
1827
  return runRemoteLeg(session, ready);
1851
1828
  },
1852
1829
  isFinished: () => finished,
@@ -1856,13 +1833,14 @@ var runDualMode = async (binary, args2, startingMode) => {
1856
1833
  } finally {
1857
1834
  for (const signal of signals) process.off(signal, onSignal);
1858
1835
  process.removeListener("exit", restoreStdin);
1836
+ await draft?.stop();
1859
1837
  relay?.stop();
1860
1838
  poller.stop();
1861
1839
  if (presenceHeartbeat) clearInterval(presenceHeartbeat);
1862
- stopTranscript?.();
1863
1840
  approver.teardown();
1864
1841
  input.close();
1865
1842
  stdin.dispose();
1843
+ if (sessionId) registerTranscript("claude", { session_id: sessionId, cwd: process.cwd() }, void 0, { ended: true });
1866
1844
  await announcePresence("session_closed", "Session ended");
1867
1845
  ownership?.stop();
1868
1846
  if (totalCostUsd > 0) {