@pushary/agent-hooks 0.89.12 → 0.90.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 (82) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +1 -0
  3. package/data/SKILL.md +1 -1
  4. package/data/cursor-plugin/skills/pushary/SKILL.md +1 -1
  5. package/data/vscode-plugin/skills/pushary/SKILL.md +1 -1
  6. package/dist/bin/pushary-bell-hook.js +4 -4
  7. package/dist/bin/pushary-bell.js +8 -8
  8. package/dist/bin/pushary-claude.js +194 -38
  9. package/dist/bin/pushary-clean.js +15 -32
  10. package/dist/bin/pushary-codex-bridge.js +37 -12
  11. package/dist/bin/pushary-codex-hook.js +45 -12
  12. package/dist/bin/pushary-codex.js +7 -7
  13. package/dist/bin/pushary-connect.js +11 -10
  14. package/dist/bin/pushary-daemon.js +186 -62
  15. package/dist/bin/pushary-disconnect.js +8 -8
  16. package/dist/bin/pushary-doctor.js +108 -25
  17. package/dist/bin/pushary-elicitation-hook.js +4 -4
  18. package/dist/bin/pushary-gemini-bridge.js +33 -11
  19. package/dist/bin/pushary-gemini-hook.js +21 -10
  20. package/dist/bin/pushary-hook.js +13 -13
  21. package/dist/bin/pushary-login.js +18 -7
  22. package/dist/bin/pushary-logout.js +6 -6
  23. package/dist/bin/pushary-mode.js +28 -21
  24. package/dist/bin/pushary-notification-hook.js +7 -7
  25. package/dist/bin/pushary-opencode-hook.js +8 -8
  26. package/dist/bin/pushary-permission-denied-hook.js +11 -11
  27. package/dist/bin/pushary-permission-hook.js +11 -11
  28. package/dist/bin/pushary-post-hook.js +14 -8
  29. package/dist/bin/pushary-prompt-hook.js +7 -7
  30. package/dist/bin/pushary-session-end-hook.js +7 -7
  31. package/dist/bin/pushary-session-start-hook.js +7 -7
  32. package/dist/bin/pushary-setup.js +168 -45
  33. package/dist/bin/pushary-stats.js +4 -4
  34. package/dist/bin/pushary-status.js +11 -10
  35. package/dist/bin/pushary-stop-hook.js +14 -8
  36. package/dist/bin/pushary-stopfailure-hook.js +7 -7
  37. package/dist/bin/pushary-suggestions.js +2 -2
  38. package/dist/bin/pushary-transcripts.d.ts +1 -0
  39. package/dist/bin/pushary-transcripts.js +79 -0
  40. package/dist/bin/pushary-upgrade.js +15 -15
  41. package/dist/bin/pushary-wait.js +5 -5
  42. package/dist/bin/pushary.js +3 -2
  43. package/dist/{chunk-KVHP5ZME.js → chunk-2LAHXHBR.js} +1 -1
  44. package/dist/{chunk-RLQWNEPM.js → chunk-43GMYSS4.js} +15 -0
  45. package/dist/{chunk-5NDB3YOE.js → chunk-54ELC7D7.js} +1 -1
  46. package/dist/{chunk-VPLQFXCH.js → chunk-5NAZ7SFS.js} +1 -1
  47. package/dist/chunk-7FE2KABY.js +156 -0
  48. package/dist/{chunk-DHR4F2CR.js → chunk-7TPQEWUZ.js} +1 -1
  49. package/dist/{chunk-Y4WVZIEI.js → chunk-AJYV3VGU.js} +1 -1
  50. package/dist/{chunk-QFJZHVVL.js → chunk-AZPIQG4G.js} +35 -1
  51. package/dist/{chunk-OCMOTJTW.js → chunk-B7UZHEBL.js} +29 -22
  52. package/dist/{chunk-7HYJPOGG.js → chunk-BQBEH6TV.js} +5 -5
  53. package/dist/{chunk-OTPJ4FUR.js → chunk-BVBEURWJ.js} +1 -1
  54. package/dist/{chunk-7DK3ZH3N.js → chunk-CELX75MW.js} +1 -1
  55. package/dist/{chunk-6VXNA5JB.js → chunk-CNPAT3HL.js} +3 -2
  56. package/dist/{chunk-327YMKP5.js → chunk-DYP6SMOR.js} +1 -1
  57. package/dist/chunk-DZDI62LY.js +145 -0
  58. package/dist/{chunk-VF3RTVWY.js → chunk-EZIEXHYM.js} +15 -7
  59. package/dist/{chunk-PS7DQ2SU.js → chunk-F2ICEWZL.js} +101 -19
  60. package/dist/{chunk-3XSP5SML.js → chunk-FQL5E6NG.js} +2 -2
  61. package/dist/{chunk-JHJHCMAF.js → chunk-FXEVRLHC.js} +20 -2
  62. package/dist/{chunk-6V7ZDH3A.js → chunk-GL3FFM7A.js} +7 -4
  63. package/dist/{chunk-NBRM3FMD.js → chunk-H2QZRQR2.js} +1 -1
  64. package/dist/{chunk-GKPURU7P.js → chunk-I463AEG3.js} +1 -1
  65. package/dist/{chunk-XREW7RQ3.js → chunk-JW4KO5AF.js} +1 -1
  66. package/dist/{chunk-OVEGEQSB.js → chunk-KUZWXPUS.js} +3 -2
  67. package/dist/{chunk-JVZX5YRJ.js → chunk-L4E2BIJ2.js} +80 -7
  68. package/dist/{chunk-RNKPLUWC.js → chunk-OSALSIUF.js} +1 -1
  69. package/dist/{chunk-MFNQ5IMW.js → chunk-PYA4FLKI.js} +32 -21
  70. package/dist/chunk-QCGNGEGE.js +15 -0
  71. package/dist/chunk-S6LYP7QA.js +127 -0
  72. package/dist/{chunk-OADGBOP7.js → chunk-TDZFPMSZ.js} +70 -11
  73. package/dist/{chunk-JJV5Q4KG.js → chunk-UZNKVRLE.js} +1 -1
  74. package/dist/{chunk-FCUUVHTS.js → chunk-VHWCVD42.js} +1 -1
  75. package/dist/{chunk-3MCEES6Z.js → chunk-VNMKIJZB.js} +15 -2
  76. package/dist/{chunk-6GU55AQQ.js → chunk-Y6BWPERV.js} +90 -7
  77. package/dist/chunk-YKLCWWEI.js +76 -0
  78. package/dist/src/index.d.ts +3 -0
  79. package/dist/src/index.js +11 -11
  80. package/package.json +3 -2
  81. package/dist/chunk-NESKLV6R.js +0 -72
  82. package/dist/chunk-WDVQ6O4C.js +0 -84
package/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.90.0
4
+
5
+ ### A hook that needs an answer is allowed to wait for one
6
+
7
+ Claude Code hooks are now registered at the 600 second budget Claude itself
8
+ allows, so a policy that waits two minutes for a phone approval is no longer cut
9
+ off at 120 seconds and turned into a denial the user never saw. Anything a hook
10
+ does that is not a decision keeps its short timeout.
11
+
12
+ A phone card is now withdrawn when the tool it guarded completes or the agent's
13
+ own client denies it, so an approval cannot arrive for something that already
14
+ finished.
15
+
16
+ ### The prompts that never reached the phone
17
+
18
+ Codex permission requests are no longer filtered by a shell matcher, so a browser
19
+ use request, a managed network request and an MCP tool request all reach the
20
+ phone the same way a shell escalation does. Codex in bypass mode now decides
21
+ before the tool runs rather than after it, which is what push-first has always
22
+ meant everywhere else.
23
+
24
+ Gemini reports its turn boundary through AfterAgent and its teardown through
25
+ SessionEnd, so a finished Gemini turn is a finished turn rather than a session
26
+ that stayed open.
27
+
28
+ A finished subagent now ends its own session instead of ending its parent's, and
29
+ appears under the session that started it. Context compaction is reported as
30
+ itself rather than as a tool that ran.
31
+
32
+ Pushary's own tools are never gated by Pushary, on the server exactly as in the
33
+ hook, so asking the user a question can no longer wait on approval to ask it.
34
+
35
+ ### Sessions end, and a daemon that cannot log in says so
36
+
37
+ A session that goes quiet is closed rather than left standing forever, and the
38
+ daemon reaps the sessions it started. `pushary daemon status` lists what it is
39
+ holding, how old each one is, and how much memory it is using.
40
+
41
+ A daemon whose key is rejected now records that and stops, instead of restarting
42
+ into the same rejection until something notices. `pushary doctor` says the daemon
43
+ needs a login, and logging in clears it.
44
+
45
+ ### Setup and doctor tell the truth about this machine
46
+
47
+ `pushary doctor` names the other tools holding the same agent hooks and how long
48
+ each of them waits, checks that the bridge the Mac app installed can be found,
49
+ and reports which Node the hooks will actually run under.
50
+
51
+ `pushary setup` gained `--verify none` for an unattended install, prints what it
52
+ would also do during a dry run, offers a way out of pairing, and can take over
53
+ hooks another tool is holding with `--take-over-hooks`. `pushary mode` now sets a
54
+ site's mode permanently unless told how long to keep it.
55
+
56
+ ### Machine identity and clean
57
+
58
+ `pushary clean` leaves the machine identity shared with the Mac app intact, keeps
59
+ one entry rather than two, and treats the identity file as the CLI's to own and
60
+ the app's to read. Transcript sync seals its recovery ledger and admits a gap
61
+ rather than syncing bookkeeping around it.
62
+
63
+ ## 0.89.13
64
+
65
+ ### Synced transcripts survive large turns and restarts safely
66
+
67
+ Oversized file reads and diffs are shortened before encryption instead of being
68
+ rejected by the server, with the removed UTF-8 byte count recorded in the turn.
69
+ Stable HMAC identifiers prevent transcript contents leaking through record ids,
70
+ and a resumed session reuses its persisted encryption key rather than conflicting
71
+ with the key already registered for that session.
72
+
73
+ Transcript recipients are now pinned on first use and later key changes stop sync
74
+ until the user explicitly runs `pushary transcripts trust --rotate`. First use
75
+ also fails closed when that pin cannot be stored, so no session key is wrapped to
76
+ an unrecorded recipient. New sessions can independently wrap their key for the
77
+ owner's phone and an optional audited compliance-recovery recipient; installations
78
+ without one remain compatible.
79
+
3
80
  ## 0.89.12
4
81
 
5
82
  ### Phone control installs as a native service, without a VM
package/README.md CHANGED
@@ -200,6 +200,7 @@ stats Show the approval moments your agents hit while not connected
200
200
  suggestions List rules mined from your own approvals
201
201
  upgrade Update the globally installed hooks to the latest version
202
202
  hook Run as a PreToolUse hook (reads stdin, writes stdout)
203
+ transcripts Show or re-trust the keys this machine seals transcripts to
203
204
  ```
204
205
 
205
206
  Run `pushary <command> --help` for one command's options.
package/data/SKILL.md CHANGED
@@ -348,7 +348,7 @@ else:
348
348
 
349
349
  If the user answers in chat before the push response arrives, call `cancel_question` before acting. If it returns `handoffAction: "stop"`, stop. Otherwise, if it returns false, poll once for 1 second and honor any phone answer that won the race.
350
350
 
351
- **A note on how long ask_user blocks:** the wait time and whether it blocks at all are governed by the site's delivery mode, which the user configures (you do not set it). In the default smart mode and push-only mode, ask_user blocks for the policy timeout; in notify-only mode it returns immediately with `answered: false` after sending the push. Always check `answered` rather than assuming the call blocked, and pass `timeoutMs` only when you need a shorter wait than the site policy.
351
+ **A note on how long ask_user blocks:** the wait time and whether it blocks at all are governed by the site's delivery mode, which the user configures (you do not set it). The four modes are "When I'm out" (`push_first`, the default), "Every time" (`push_only`), "Updates" (`notify_only`) and "Terminal" (`terminal_only`). In When I'm out, ask_user blocks for the push-first window (45 seconds by default) and the phone is only asked when the user is away from their terminal or their Mac; in Every time it blocks for the policy timeout and the phone is always asked; in Updates it returns immediately with `answered: false` after telling the phone, because the decision belongs in the current client; in Terminal nothing reaches the phone and it also returns immediately with `answered: false`. Always check `answered` rather than assuming the call blocked, and pass `timeoutMs` only when you need a shorter wait than the site policy.
352
352
 
353
353
  ## Identifying Your Agent
354
354
 
@@ -341,7 +341,7 @@ else:
341
341
 
342
342
  If the user answers in chat before the push response arrives, call `cancel_question` before acting. If it returns `handoffAction: "stop"`, stop. Otherwise, if it returns false, poll once for 1 second and honor any phone answer that won the race.
343
343
 
344
- **A note on how long ask_user blocks:** the wait time and whether it blocks at all are governed by the site's delivery mode, which the user configures (you do not set it). In the default smart mode and push-only mode, ask_user blocks for the policy timeout; in notify-only mode it returns immediately with `answered: false` after sending the push. Always check `answered` rather than assuming the call blocked, and pass `timeoutMs` only when you need a shorter wait than the site policy.
344
+ **A note on how long ask_user blocks:** the wait time and whether it blocks at all are governed by the site's delivery mode, which the user configures (you do not set it). The four modes are "When I'm out" (`push_first`, the default), "Every time" (`push_only`), "Updates" (`notify_only`) and "Terminal" (`terminal_only`). In When I'm out, ask_user blocks for the push-first window (45 seconds by default) and the phone is only asked when the user is away from their terminal or their Mac; in Every time it blocks for the policy timeout and the phone is always asked; in Updates it returns immediately with `answered: false` after telling the phone, because the decision belongs in the current client; in Terminal nothing reaches the phone and it also returns immediately with `answered: false`. Always check `answered` rather than assuming the call blocked, and pass `timeoutMs` only when you need a shorter wait than the site policy.
345
345
 
346
346
  ## Identifying Your Agent
347
347
 
@@ -341,7 +341,7 @@ else:
341
341
 
342
342
  If the user answers in chat before the push response arrives, call `cancel_question` before acting. If it returns `handoffAction: "stop"`, stop. Otherwise, if it returns false, poll once for 1 second and honor any phone answer that won the race.
343
343
 
344
- **A note on how long ask_user blocks:** the wait time and whether it blocks at all are governed by the site's delivery mode, which the user configures (you do not set it). In the default smart mode and push-only mode, ask_user blocks for the policy timeout; in notify-only mode it returns immediately with `answered: false` after sending the push. Always check `answered` rather than assuming the call blocked, and pass `timeoutMs` only when you need a shorter wait than the site policy.
344
+ **A note on how long ask_user blocks:** the wait time and whether it blocks at all are governed by the site's delivery mode, which the user configures (you do not set it). The four modes are "When I'm out" (`push_first`, the default), "Every time" (`push_only`), "Updates" (`notify_only`) and "Terminal" (`terminal_only`). In When I'm out, ask_user blocks for the push-first window (45 seconds by default) and the phone is only asked when the user is away from their terminal or their Mac; in Every time it blocks for the policy timeout and the phone is always asked; in Updates it returns immediately with `answered: false` after telling the phone, because the decision belongs in the current client; in Terminal nothing reaches the phone and it also returns immediately with `answered: false`. Always check `answered` rather than assuming the call blocked, and pass `timeoutMs` only when you need a shorter wait than the site policy.
345
345
 
346
346
  ## Identifying Your Agent
347
347
 
@@ -1,16 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  handleBell
4
- } from "../chunk-VPLQFXCH.js";
4
+ } from "../chunk-5NAZ7SFS.js";
5
5
  import {
6
6
  readHookInput
7
7
  } from "../chunk-6CUUA7HO.js";
8
- import "../chunk-RNKPLUWC.js";
8
+ import "../chunk-OSALSIUF.js";
9
9
  import "../chunk-BC3VCZ3E.js";
10
- import "../chunk-XREW7RQ3.js";
10
+ import "../chunk-JW4KO5AF.js";
11
11
  import "../chunk-4LL6LG5X.js";
12
12
  import "../chunk-2UMNXADU.js";
13
- import "../chunk-3MCEES6Z.js";
13
+ import "../chunk-VNMKIJZB.js";
14
14
 
15
15
  // bin/pushary-bell-hook.ts
16
16
  var main = async () => {
@@ -5,14 +5,14 @@ import {
5
5
  liveAgentCount,
6
6
  ring,
7
7
  upgradeThreshold
8
- } from "../chunk-VPLQFXCH.js";
8
+ } from "../chunk-5NAZ7SFS.js";
9
9
  import {
10
10
  guardBinary
11
- } from "../chunk-VF3RTVWY.js";
11
+ } from "../chunk-EZIEXHYM.js";
12
12
  import "../chunk-L4ZCFMWW.js";
13
13
  import {
14
14
  claudeSettings
15
- } from "../chunk-OVEGEQSB.js";
15
+ } from "../chunk-KUZWXPUS.js";
16
16
  import {
17
17
  createIo
18
18
  } from "../chunk-5RUIFDTP.js";
@@ -21,22 +21,22 @@ import {
21
21
  } from "../chunk-GMXKITVA.js";
22
22
  import {
23
23
  exitOnHelpFlag
24
- } from "../chunk-OTPJ4FUR.js";
25
- import "../chunk-RLQWNEPM.js";
24
+ } from "../chunk-BVBEURWJ.js";
25
+ import "../chunk-43GMYSS4.js";
26
26
  import "../chunk-DINDL2CF.js";
27
- import "../chunk-RNKPLUWC.js";
27
+ import "../chunk-OSALSIUF.js";
28
28
  import "../chunk-BC3VCZ3E.js";
29
29
  import "../chunk-7QLSKOSU.js";
30
30
  import {
31
31
  EXIT
32
32
  } from "../chunk-KZERVKTD.js";
33
- import "../chunk-XREW7RQ3.js";
33
+ import "../chunk-JW4KO5AF.js";
34
34
  import {
35
35
  readJsonSafe,
36
36
  writeJsonAtomic
37
37
  } from "../chunk-4LL6LG5X.js";
38
38
  import "../chunk-2UMNXADU.js";
39
- import "../chunk-3MCEES6Z.js";
39
+ import "../chunk-VNMKIJZB.js";
40
40
 
41
41
  // src/bell/install.ts
42
42
  import { join } from "path";
@@ -1,24 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ changedRecipientsMessage,
4
+ loadPin,
5
+ savePin,
6
+ verifyRecipients
7
+ } from "../chunk-YKLCWWEI.js";
2
8
  import {
3
9
  ensureDaemonRunning
4
- } from "../chunk-PS7DQ2SU.js";
10
+ } from "../chunk-F2ICEWZL.js";
5
11
  import {
6
12
  buildTranscriptRecords,
7
13
  decodePublicKey,
8
14
  generateSessionKey,
9
15
  wrapSessionKey
10
- } from "../chunk-WDVQ6O4C.js";
16
+ } from "../chunk-7FE2KABY.js";
11
17
  import {
12
18
  fetchTranscriptRecipients,
13
19
  uploadTranscriptRecords
14
20
  } from "../chunk-QZCFVWA7.js";
15
21
  import {
16
22
  transcriptsEnabled
17
- } from "../chunk-JJV5Q4KG.js";
23
+ } from "../chunk-UZNKVRLE.js";
18
24
  import {
25
+ SESSION_IDLE_TIMEOUT_MS,
26
+ describeIdleWindow,
19
27
  startProcessOwnership,
20
28
  withSpawnProcessOwnership
21
- } from "../chunk-6GU55AQQ.js";
29
+ } from "../chunk-Y6BWPERV.js";
22
30
  import {
23
31
  needsShell,
24
32
  spawnClaude
@@ -30,9 +38,10 @@ import {
30
38
  import {
31
39
  fetchModeState,
32
40
  getPolicy,
41
+ phoneDrivenEnv,
33
42
  repoKeyFor,
34
43
  reportEvent
35
- } from "../chunk-OADGBOP7.js";
44
+ } from "../chunk-TDZFPMSZ.js";
36
45
  import {
37
46
  DEFAULT_SESSION,
38
47
  askUser,
@@ -44,25 +53,25 @@ import {
44
53
  removePendingCommand,
45
54
  savePendingCommand,
46
55
  waitForAnswer
47
- } from "../chunk-JHJHCMAF.js";
56
+ } from "../chunk-FXEVRLHC.js";
48
57
  import {
49
58
  UNNAMED_ACTION,
50
59
  deriveToolTarget,
51
60
  describeToolCall,
52
61
  scopePathFor
53
- } from "../chunk-JVZX5YRJ.js";
62
+ } from "../chunk-L4E2BIJ2.js";
54
63
  import {
55
64
  getMachineId
56
- } from "../chunk-5NDB3YOE.js";
57
- import "../chunk-OVEGEQSB.js";
65
+ } from "../chunk-54ELC7D7.js";
66
+ import "../chunk-KUZWXPUS.js";
58
67
  import "../chunk-DINDL2CF.js";
59
- import "../chunk-RNKPLUWC.js";
68
+ import "../chunk-OSALSIUF.js";
60
69
  import "../chunk-BC3VCZ3E.js";
61
70
  import "../chunk-BSZYIAZL.js";
62
71
  import "../chunk-SAF6HGAA.js";
63
72
  import "../chunk-7QLSKOSU.js";
64
73
  import "../chunk-KZERVKTD.js";
65
- import "../chunk-XREW7RQ3.js";
74
+ import "../chunk-JW4KO5AF.js";
66
75
  import "../chunk-4LL6LG5X.js";
67
76
  import {
68
77
  getApiKey,
@@ -71,7 +80,7 @@ import {
71
80
  import {
72
81
  KILL_REASON,
73
82
  resolveGate
74
- } from "../chunk-3MCEES6Z.js";
83
+ } from "../chunk-VNMKIJZB.js";
75
84
 
76
85
  // src/wrapper/claudeBinary.ts
77
86
  import { statSync } from "fs";
@@ -147,13 +156,82 @@ var runLocalPassthrough = (binary, args2) => {
147
156
  import { basename } from "path";
148
157
 
149
158
  // src/transcript-sync.ts
159
+ import { homedir as homedir2 } from "os";
160
+ import { join as join3, resolve } from "path";
161
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
162
+
163
+ // src/transcript-session-key.ts
164
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync as statSync2, writeFileSync } from "fs";
150
165
  import { homedir } from "os";
151
- import { join as join2, resolve } from "path";
152
- import { existsSync, readFileSync } from "fs";
166
+ import { join as join2 } from "path";
167
+ var sessionDir = () => process.env.PUSHARY_SESSION_KEY_DIR?.trim() || join2(homedir(), ".pushary", "sessions");
168
+ var SESSION_KEY_TTL_MS = 48 * 60 * 60 * 1e3;
169
+ var SAFE_SESSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
170
+ var fileFor = (sessionId) => SAFE_SESSION_ID.test(sessionId) ? join2(sessionDir(), `${sessionId}.json`) : null;
171
+ var loadSessionKey = (sessionId) => {
172
+ const path = fileFor(sessionId);
173
+ if (!path || !existsSync(path)) return null;
174
+ try {
175
+ if (Date.now() - statSync2(path).mtimeMs > SESSION_KEY_TTL_MS) return null;
176
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
177
+ const key = typeof raw.sessionKey === "string" ? Buffer.from(raw.sessionKey, "base64") : null;
178
+ if (!key || key.length !== 32) return null;
179
+ return {
180
+ sessionKey: new Uint8Array(key),
181
+ wrappedKey: typeof raw.wrappedKey === "string" ? raw.wrappedKey : null,
182
+ providerWrappedKey: typeof raw.providerWrappedKey === "string" ? raw.providerWrappedKey : null,
183
+ providerKeyId: typeof raw.providerKeyId === "string" ? raw.providerKeyId : null,
184
+ keyUploaded: raw.keyUploaded === true
185
+ };
186
+ } catch {
187
+ return null;
188
+ }
189
+ };
190
+ var saveSessionKey = (sessionId, value) => {
191
+ const path = fileFor(sessionId);
192
+ if (!path) return;
193
+ try {
194
+ mkdirSync(sessionDir(), { recursive: true, mode: 448 });
195
+ writeFileSync(
196
+ path,
197
+ JSON.stringify({
198
+ sessionKey: Buffer.from(value.sessionKey).toString("base64"),
199
+ wrappedKey: value.wrappedKey,
200
+ providerWrappedKey: value.providerWrappedKey,
201
+ providerKeyId: value.providerKeyId,
202
+ keyUploaded: value.keyUploaded
203
+ }),
204
+ { mode: 384 }
205
+ );
206
+ } catch {
207
+ }
208
+ };
209
+ var sweepStaleSessionKeys = (now = Date.now()) => {
210
+ const dir = sessionDir();
211
+ if (!existsSync(dir)) return 0;
212
+ let removed = 0;
213
+ try {
214
+ for (const name of readdirSync(dir)) {
215
+ if (!name.endsWith(".json")) continue;
216
+ const path = join2(dir, name);
217
+ try {
218
+ if (now - statSync2(path).mtimeMs <= SESSION_KEY_TTL_MS) continue;
219
+ rmSync(path, { force: true });
220
+ removed += 1;
221
+ } catch {
222
+ }
223
+ }
224
+ } catch {
225
+ return removed;
226
+ }
227
+ return removed;
228
+ };
229
+
230
+ // src/transcript-sync.ts
153
231
  var claudeTranscriptPath = (cwd, sessionId) => {
154
232
  const projectId = resolve(cwd).replace(/[^a-zA-Z0-9-]/g, "-");
155
- const configDir = process.env.CLAUDE_CONFIG_DIR || join2(homedir(), ".claude");
156
- return join2(configDir, "projects", projectId, `${sessionId}.jsonl`);
233
+ const configDir = process.env.CLAUDE_CONFIG_DIR || join3(homedir2(), ".claude");
234
+ return join3(configDir, "projects", projectId, `${sessionId}.jsonl`);
157
235
  };
158
236
  var createSyncState = () => ({
159
237
  sessionKey: null,
@@ -241,7 +319,7 @@ var chunkRecords = (records) => {
241
319
  };
242
320
  var readLines = (path) => {
243
321
  try {
244
- return existsSync(path) ? readFileSync(path, "utf-8").split("\n") : [];
322
+ return existsSync2(path) ? readFileSync2(path, "utf-8").split("\n") : [];
245
323
  } catch {
246
324
  return [];
247
325
  }
@@ -249,19 +327,49 @@ var readLines = (path) => {
249
327
  var IDLE_CEILING_MS = 6e4;
250
328
  var nextInterval = (previousMs, idle, baseMs, maxMs) => idle ? Math.min(previousMs * 2, maxMs) : baseMs;
251
329
  var startTranscriptSync = (opts) => {
330
+ let warnedAboutRecipients = false;
331
+ const fetchPinnedRecipients = async () => {
332
+ const offer = await fetchTranscriptRecipients(opts.apiKey);
333
+ if (!offer) return null;
334
+ const verdict = verifyRecipients(offer, loadPin());
335
+ if (verdict.kind === "first-use" && !savePin(verdict.pin)) return null;
336
+ if (verdict.kind === "changed") {
337
+ if (!warnedAboutRecipients) {
338
+ warnedAboutRecipients = true;
339
+ process.stderr.write(`${changedRecipientsMessage(verdict, offer)}
340
+ `);
341
+ }
342
+ return null;
343
+ }
344
+ return offer;
345
+ };
252
346
  const uploader = {
253
- fetchTranscriptRecipients: () => fetchTranscriptRecipients(opts.apiKey),
347
+ fetchTranscriptRecipients: fetchPinnedRecipients,
254
348
  uploadRecords: (params) => uploadTranscriptRecords(opts.apiKey, params)
255
349
  };
256
350
  const path = claudeTranscriptPath(opts.cwd, opts.sessionId);
257
- let state = createSyncState();
351
+ const persisted = loadSessionKey(opts.sessionId);
352
+ let state = persisted ? { ...createSyncState(), ...persisted } : createSyncState();
353
+ sweepStaleSessionKeys();
354
+ let persistedUploaded = persisted?.keyUploaded ?? false;
258
355
  let running = true;
259
356
  let inFlight = false;
260
357
  const tick = async () => {
261
358
  if (!running || inFlight) return;
262
359
  inFlight = true;
263
360
  try {
361
+ const before = state.sessionKey;
264
362
  state = await syncTick(readLines(path), state, uploader, opts.sessionId, opts.machineId);
363
+ if (state.sessionKey && (before !== state.sessionKey || state.keyUploaded !== persistedUploaded)) {
364
+ persistedUploaded = state.keyUploaded;
365
+ saveSessionKey(opts.sessionId, {
366
+ sessionKey: state.sessionKey,
367
+ wrappedKey: state.wrappedKey,
368
+ providerWrappedKey: state.providerWrappedKey,
369
+ providerKeyId: state.providerKeyId,
370
+ keyUploaded: state.keyUploaded
371
+ });
372
+ }
265
373
  } catch {
266
374
  } finally {
267
375
  inFlight = false;
@@ -292,9 +400,9 @@ var startTranscriptSync = (opts) => {
292
400
 
293
401
  // src/wrapper/sdkLoader.ts
294
402
  import { spawn } from "child_process";
295
- import { existsSync as existsSync2, mkdirSync, writeFileSync } from "fs";
296
- import { homedir as homedir2 } from "os";
297
- import { join as join3 } from "path";
403
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
404
+ import { homedir as homedir3 } from "os";
405
+ import { join as join4 } from "path";
298
406
  import { pathToFileURL } from "url";
299
407
  var userMessage = (text) => ({
300
408
  type: "user",
@@ -304,8 +412,8 @@ var userMessage = (text) => ({
304
412
  var SDK_VERSION = "0.3.207";
305
413
  var SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
306
414
  var SDK_SPEC = `${SDK_PACKAGE}@${SDK_VERSION}`;
307
- var sdkCacheDir = () => process.env.PUSHARY_REMOTE_SDK_DIR?.trim() || join3(homedir2(), ".pushary", "remote-sdk");
308
- var cachedSdkEntry = () => join3(sdkCacheDir(), "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
415
+ var sdkCacheDir = () => process.env.PUSHARY_REMOTE_SDK_DIR?.trim() || join4(homedir3(), ".pushary", "remote-sdk");
416
+ var cachedSdkEntry = () => join4(sdkCacheDir(), "node_modules", "@anthropic-ai", "claude-agent-sdk", "sdk.mjs");
309
417
  var importSdk = async (specifierOrUrl) => {
310
418
  try {
311
419
  const mod = await import(specifierOrUrl);
@@ -320,7 +428,7 @@ var loadClaudeSdk = async () => {
320
428
  const fromResolution = await importSdk(SDK_PACKAGE);
321
429
  if (fromResolution) return fromResolution;
322
430
  const entry = cachedSdkEntry();
323
- if (existsSync2(entry)) return importSdk(pathToFileURL(entry).href);
431
+ if (existsSync3(entry)) return importSdk(pathToFileURL(entry).href);
324
432
  return null;
325
433
  };
326
434
  var ensureClaudeSdk = async (log) => {
@@ -328,10 +436,10 @@ var ensureClaudeSdk = async (log) => {
328
436
  if (present) return present;
329
437
  const dir = sdkCacheDir();
330
438
  try {
331
- mkdirSync(dir, { recursive: true });
332
- const pkgJson = join3(dir, "package.json");
333
- if (!existsSync2(pkgJson)) {
334
- writeFileSync(pkgJson, `${JSON.stringify({ name: "pushary-remote-sdk", private: true })}
439
+ mkdirSync2(dir, { recursive: true });
440
+ const pkgJson = join4(dir, "package.json");
441
+ if (!existsSync3(pkgJson)) {
442
+ writeFileSync2(pkgJson, `${JSON.stringify({ name: "pushary-remote-sdk", private: true })}
335
443
  `);
336
444
  }
337
445
  } catch {
@@ -1262,14 +1370,30 @@ var runLocalLeg = (session, deps = {}) => {
1262
1370
 
1263
1371
  // src/wrapper/remoteLoop.ts
1264
1372
  var errMsg = (err) => err instanceof Error ? err.message : String(err);
1373
+ var scheduleWithTimer = (fire, delayMs) => {
1374
+ const timer = setTimeout(fire, delayMs);
1375
+ timer.unref?.();
1376
+ return () => clearTimeout(timer);
1377
+ };
1378
+ var isTurnActivity = (message) => message.type !== "system" && message.type !== "result";
1379
+ var isInit = (message) => message.type === "system" && message.subtype === "init";
1265
1380
  var DEFAULT_MAX_RESTARTS = 5;
1266
1381
  var runRemoteLoop = async (deps) => {
1267
1382
  const maxRestarts = deps.maxRestarts ?? DEFAULT_MAX_RESTARTS;
1268
1383
  const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1269
1384
  const backoff = deps.backoffMs ?? ((attempt) => Math.min(1e3 * 2 ** attempt, 15e3));
1385
+ const idleTimeoutMs = deps.idleTimeoutMs ?? SESSION_IDLE_TIMEOUT_MS;
1386
+ const scheduleIdle = deps.scheduleIdle ?? scheduleWithTimer;
1270
1387
  let resumeId = deps.initialResume;
1271
1388
  let totalCost = 0;
1272
1389
  let restarts = 0;
1390
+ let idleExpired = false;
1391
+ let cancelIdle;
1392
+ const disarmIdle = () => {
1393
+ cancelIdle?.();
1394
+ cancelIdle = void 0;
1395
+ };
1396
+ const ended = (exitCode, endedBy) => ({ exitCode, totalCostUsd: totalCost, endedBy });
1273
1397
  while (!deps.signal.aborted) {
1274
1398
  deps.input.detachConsumer();
1275
1399
  let query;
@@ -1287,7 +1411,7 @@ var runRemoteLoop = async (deps) => {
1287
1411
  });
1288
1412
  } catch (err) {
1289
1413
  deps.log?.(`[pushary] remote mode: query failed to start: ${errMsg(err)}`);
1290
- return { exitCode: 1, totalCostUsd: totalCost };
1414
+ return ended(1, "failed");
1291
1415
  }
1292
1416
  const onAbort = () => {
1293
1417
  deps.input.close();
@@ -1296,10 +1420,24 @@ var runRemoteLoop = async (deps) => {
1296
1420
  };
1297
1421
  if (deps.signal.aborted) onAbort();
1298
1422
  else deps.signal.addEventListener("abort", onAbort, { once: true });
1423
+ let turnOpen = false;
1424
+ const stillResting = (deliveredAtRest) => !turnOpen && deps.input.bufferedCount === 0 && deps.input.stats.delivered === deliveredAtRest;
1425
+ const armIdle = () => {
1426
+ disarmIdle();
1427
+ const deliveredAtRest = deps.input.stats.delivered;
1428
+ cancelIdle = scheduleIdle(() => {
1429
+ cancelIdle = void 0;
1430
+ if (!stillResting(deliveredAtRest)) return;
1431
+ idleExpired = true;
1432
+ deps.log?.(`[pushary] remote mode: no phone instruction for ${describeIdleWindow(idleTimeoutMs)}, ending the session`);
1433
+ onAbort();
1434
+ }, idleTimeoutMs);
1435
+ };
1436
+ const restsAfter = (message) => deps.input.bufferedCount === 0 && (message.type === "result" || isInit(message) && !turnOpen);
1299
1437
  try {
1300
1438
  for await (const message of query) {
1301
1439
  deps.onMessage?.(message);
1302
- if (message.type === "system" && message.subtype === "init" && typeof message.session_id === "string") {
1440
+ if (isInit(message) && typeof message.session_id === "string") {
1303
1441
  resumeId = message.session_id;
1304
1442
  deps.onSessionId?.(resumeId);
1305
1443
  }
@@ -1307,16 +1445,23 @@ var runRemoteLoop = async (deps) => {
1307
1445
  totalCost = message.total_cost_usd;
1308
1446
  deps.onCost?.(totalCost);
1309
1447
  }
1448
+ if (message.type === "result") turnOpen = false;
1449
+ else if (isTurnActivity(message)) turnOpen = true;
1450
+ if (restsAfter(message)) armIdle();
1310
1451
  }
1452
+ disarmIdle();
1311
1453
  deps.signal.removeEventListener("abort", onAbort);
1312
- return { exitCode: deps.signal.aborted ? 130 : 0, totalCostUsd: totalCost };
1454
+ if (idleExpired) return ended(0, "idle");
1455
+ return deps.signal.aborted ? ended(130, "aborted") : ended(0, "input-closed");
1313
1456
  } catch (err) {
1457
+ disarmIdle();
1314
1458
  deps.signal.removeEventListener("abort", onAbort);
1315
- if (deps.signal.aborted) return { exitCode: 130, totalCostUsd: totalCost };
1459
+ if (idleExpired) return ended(0, "idle");
1460
+ if (deps.signal.aborted) return ended(130, "aborted");
1316
1461
  restarts++;
1317
1462
  if (restarts > maxRestarts) {
1318
1463
  deps.log?.(`[pushary] remote mode: too many restarts, giving up: ${errMsg(err)}`);
1319
- return { exitCode: 1, totalCostUsd: totalCost };
1464
+ return ended(1, "failed");
1320
1465
  }
1321
1466
  deps.log?.(
1322
1467
  `[pushary] remote mode: transient error, resuming session (attempt ${restarts}/${maxRestarts}): ${errMsg(err)}`
@@ -1324,15 +1469,18 @@ var runRemoteLoop = async (deps) => {
1324
1469
  await sleep(backoff(restarts));
1325
1470
  }
1326
1471
  }
1327
- return { exitCode: 130, totalCostUsd: totalCost };
1472
+ return ended(130, "aborted");
1328
1473
  };
1329
1474
 
1330
1475
  // src/wrapper/remoteLeg.ts
1331
1476
  var CTRL_RIGHT_BRACKET = "";
1332
1477
  var CTRL_C = "";
1333
- var runRemoteLeg = async (session, sdk) => {
1478
+ var terminalAttended = () => Boolean(process.stdin.isTTY);
1479
+ var runRemoteLeg = async (session, sdk, deps = {}) => {
1334
1480
  const legAbort = new AbortController();
1481
+ const attended = deps.attended ?? terminalAttended();
1335
1482
  let switchBack = false;
1483
+ let idleEnded = false;
1336
1484
  const control = {
1337
1485
  // The running query already drains the shared input queue, so a freshly pushed
1338
1486
  // phone command is delivered without any action here.
@@ -1367,7 +1515,7 @@ var runRemoteLeg = async (session, sdk) => {
1367
1515
  permissionMode: session.permissionMode,
1368
1516
  // The SDK REPLACES the subprocess env; spread process.env and mark the
1369
1517
  // recursion guard so a wrapper-spawned claude cannot re-enter the wrapper.
1370
- env: { ...process.env, [WRAPPER_ACTIVE_ENV]: "1" },
1518
+ env: phoneDrivenEnv({ ...process.env, [WRAPPER_ACTIVE_ENV]: "1" }),
1371
1519
  cwd: process.cwd(),
1372
1520
  // Run the user's OWN claude (auth + version parity); Windows .cmd shims keep
1373
1521
  // the SDK's bundled binary since the SDK cannot spawn a shim directly.
@@ -1375,13 +1523,20 @@ var runRemoteLeg = async (session, sdk) => {
1375
1523
  onSessionId: (id) => session.setSessionId(id),
1376
1524
  onMessage: (message) => session.streamMessage(message),
1377
1525
  log: (message) => process.stderr.write(`${message}
1378
- `)
1526
+ `),
1527
+ idleTimeoutMs: deps.idleTimeoutMs,
1528
+ scheduleIdle: deps.scheduleIdle
1379
1529
  });
1380
1530
  exitCode = result.exitCode;
1531
+ idleEnded = result.endedBy === "idle";
1381
1532
  } finally {
1382
1533
  session.stdin.releaseToChild();
1383
1534
  session.setActiveLeg(null);
1384
1535
  }
1536
+ if (idleEnded && attended) {
1537
+ process.stderr.write("[pushary] handing the terminal back to local mode\n");
1538
+ return "switch";
1539
+ }
1385
1540
  return switchBack ? "switch" : { type: "exit", code: exitCode };
1386
1541
  };
1387
1542
 
@@ -1511,6 +1666,7 @@ var runDualMode = async (binary, args2, startingMode) => {
1511
1666
  if (!sessionId) return Promise.resolve();
1512
1667
  try {
1513
1668
  await ensureOwnership();
1669
+ ownership?.touch();
1514
1670
  await reportEvent(
1515
1671
  { event, agentType: "claude_code", agentName, action, sessionId, machineId, pid: process.pid },
1516
1672
  { maxAttempts: event === "session_start" ? 4 : 2, timeoutMs: 4e3, requireOk: event === "session_start" }