dsh-agy-cli 0.0.0-stage → 0.12.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 (91) hide show
  1. package/LICENSE +31 -0
  2. package/README.en.md +48 -0
  3. package/README.md +67 -2
  4. package/agents/dsh-agy-image-view.md +17 -0
  5. package/agents/dsh-agy-mcp-executor.md +32 -0
  6. package/agents/dsh-agy-read-only.md +20 -0
  7. package/agents/dsh-agy-tool-free.md +16 -0
  8. package/agents/dsh-agy-workspace-write.md +23 -0
  9. package/bin/dsh-agy-cli.js +9 -0
  10. package/bin/win32-x64/agy-launcher.exe +0 -0
  11. package/cordis.patch.yml +16 -0
  12. package/dist/client.js +4259 -0
  13. package/lib/agent-installer.d.ts +66 -0
  14. package/lib/agent-installer.js +147 -0
  15. package/lib/agent-presets.d.ts +42 -0
  16. package/lib/agent-presets.js +130 -0
  17. package/lib/agents-cli.d.ts +3 -0
  18. package/lib/agents-cli.js +110 -0
  19. package/lib/agy/diagnostics.d.ts +29 -0
  20. package/lib/agy/diagnostics.js +109 -0
  21. package/lib/agy/errors.d.ts +20 -0
  22. package/lib/agy/errors.js +77 -0
  23. package/lib/agy/experimental-transport.d.ts +79 -0
  24. package/lib/agy/experimental-transport.js +612 -0
  25. package/lib/agy/keepalive.d.ts +140 -0
  26. package/lib/agy/keepalive.js +445 -0
  27. package/lib/agy/limiter.d.ts +32 -0
  28. package/lib/agy/limiter.js +111 -0
  29. package/lib/agy/log.d.ts +214 -0
  30. package/lib/agy/log.js +503 -0
  31. package/lib/agy/models.d.ts +49 -0
  32. package/lib/agy/models.js +203 -0
  33. package/lib/agy/overrides.d.ts +17 -0
  34. package/lib/agy/overrides.js +73 -0
  35. package/lib/agy/parser.d.ts +102 -0
  36. package/lib/agy/parser.js +492 -0
  37. package/lib/agy/persistent-transport.d.ts +101 -0
  38. package/lib/agy/persistent-transport.js +753 -0
  39. package/lib/agy/process.d.ts +144 -0
  40. package/lib/agy/process.js +452 -0
  41. package/lib/agy/redact.d.ts +3 -0
  42. package/lib/agy/redact.js +24 -0
  43. package/lib/agy/stream-protocol.d.ts +50 -0
  44. package/lib/agy/stream-protocol.js +156 -0
  45. package/lib/agy/windows-launcher.d.ts +21 -0
  46. package/lib/agy/windows-launcher.js +76 -0
  47. package/lib/diagnostics.d.ts +78 -0
  48. package/lib/diagnostics.js +162 -0
  49. package/lib/doctor.d.ts +138 -0
  50. package/lib/doctor.js +940 -0
  51. package/lib/dsh/context.d.ts +133 -0
  52. package/lib/dsh/context.js +352 -0
  53. package/lib/dsh-home.d.ts +6 -0
  54. package/lib/dsh-home.js +11 -0
  55. package/lib/index.d.ts +54 -0
  56. package/lib/index.js +267 -0
  57. package/lib/mcp/agy-registration.d.ts +70 -0
  58. package/lib/mcp/agy-registration.js +245 -0
  59. package/lib/mcp/bridge.d.ts +73 -0
  60. package/lib/mcp/bridge.js +295 -0
  61. package/lib/mcp/endpoint.d.ts +41 -0
  62. package/lib/mcp/endpoint.js +142 -0
  63. package/lib/mcp/protocol.d.ts +107 -0
  64. package/lib/mcp/protocol.js +197 -0
  65. package/lib/mcp/tool-projection.d.ts +64 -0
  66. package/lib/mcp/tool-projection.js +168 -0
  67. package/lib/provider/agy.d.ts +153 -0
  68. package/lib/provider/agy.js +2159 -0
  69. package/lib/provider/config.d.ts +226 -0
  70. package/lib/provider/config.js +294 -0
  71. package/lib/provider/error-codes.d.ts +22 -0
  72. package/lib/provider/error-codes.js +29 -0
  73. package/lib/provider/image-bridge.d.ts +60 -0
  74. package/lib/provider/image-bridge.js +290 -0
  75. package/lib/provider/mock.d.ts +15 -0
  76. package/lib/provider/mock.js +78 -0
  77. package/lib/provider/prompt-budget.d.ts +45 -0
  78. package/lib/provider/prompt-budget.js +596 -0
  79. package/lib/provider/serialize.d.ts +15 -0
  80. package/lib/provider/serialize.js +82 -0
  81. package/lib/provider/tool-protocol.d.ts +80 -0
  82. package/lib/provider/tool-protocol.js +848 -0
  83. package/lib/provider/tool-schema-file.d.ts +11 -0
  84. package/lib/provider/tool-schema-file.js +27 -0
  85. package/lib/session/file-store.d.ts +56 -0
  86. package/lib/session/file-store.js +186 -0
  87. package/lib/session/store.d.ts +49 -0
  88. package/lib/session/store.js +62 -0
  89. package/lib/web/router.d.ts +19 -0
  90. package/lib/web/router.js +994 -0
  91. package/package.json +115 -4
@@ -0,0 +1,140 @@
1
+ import { runProcess, type ProcessResult } from './process.js';
2
+ /** Windows Credential Manager target AGY stores its OAuth blob under. */
3
+ export declare const AGY_KEYRING_TARGET = "gemini:antigravity";
4
+ export interface KeyringTokenState {
5
+ /** Epoch ms parsed from the blob's `token.expiry`, or undefined when unreadable. */
6
+ expiresAtMs?: number | undefined;
7
+ }
8
+ /** One decoded read of the Credential Manager blob. */
9
+ export interface KeyringBlob {
10
+ accessToken: string | null;
11
+ email?: string | undefined;
12
+ name?: string | undefined;
13
+ /** Epoch ms from `token.expiry`; undefined when the blob has no readable expiry. */
14
+ expiresAtMs?: number | undefined;
15
+ }
16
+ /**
17
+ * Synchronous expiry read. Non-Windows only: on Windows every `execFileSync`
18
+ * spawn blocks the host's event loop for 0.2–3 s (measured 2026-10-23), which is
19
+ * what made `/status` take 10–22 s. Windows callers must use the async
20
+ * `readKeyringTokenState()` / `readKeyringBlob()` instead.
21
+ */
22
+ export declare function readKeyringTokenStateSync(): KeyringTokenState;
23
+ /**
24
+ * Current keyring contents. Never throws and never blocks the event loop.
25
+ * `force` bypasses the freshness window (used after a renewal). A concurrent
26
+ * read joins the one already running unless the caller forced.
27
+ */
28
+ export declare function readKeyringBlob(options?: {
29
+ force?: boolean | undefined;
30
+ }): Promise<KeyringBlob>;
31
+ /** Expiry-only view of `readKeyringBlob`, for the keep-alive decision. */
32
+ export declare function readKeyringTokenState(options?: {
33
+ force?: boolean | undefined;
34
+ }): Promise<KeyringTokenState>;
35
+ /**
36
+ * Arguments for the wake-up run. Deliberately minimal:
37
+ * - the prompt goes on argv (`-p .`). AGY refuses an empty prompt ("empty
38
+ * prompt. Usage: agy --print ...") and reads a single "." as "look at the
39
+ * working directory", which is the cheapest turn that still authenticates.
40
+ * The adapter's `-p ''` + stdin-frame shape cannot be reused here: AGY exits
41
+ * with code 2 and `--input-format stream-json reads prompts from stdin, so a
42
+ * prompt given on the command line would be ignored` — measured 2026-10-22,
43
+ * and that rejection was silent enough to make the first version look like a
44
+ * working renewal that never rotated anything.
45
+ * - no `--input-format` / `--output-format`: they only matter for framing a
46
+ * stream we are not reading; leaving them out also avoids the conflict above.
47
+ * - `--effort low` is passed anyway: some gemini-* ids refuse to start without
48
+ * an `--effort` flag, and if the cancellation ever loses the race, the turn
49
+ * that runs is the cheapest one available instead of the 'high' default.
50
+ * - no `--agent`: this must not depend on a template being installed.
51
+ * - `--disable-slash-commands`: a bare "." must never be parsed as a command.
52
+ */
53
+ export declare function buildKeepaliveArgs(model?: string): string[];
54
+ export interface KeepaliveOptions {
55
+ /** AGY executable; empty resolves through PATH/env like the adapter does. */
56
+ executable?: string | undefined;
57
+ /** Model id for the wake-up argv; empty omits `--model` and uses AGY's default. */
58
+ model?: string | undefined;
59
+ /** Refresh once the token has less than this much life left. */
60
+ leadMs?: number | undefined;
61
+ /** Wall-clock ceiling for the wake-up process. */
62
+ timeoutMs?: number | undefined;
63
+ /** Directory the child runs in. Defaults to the user's home, not the project. */
64
+ cwd?: string | undefined;
65
+ /** Injected for tests; defaults to `runProcess`. */
66
+ run?: ((request: Parameters<typeof runProcess>[0]) => Promise<ProcessResult>) | undefined;
67
+ /** Injected for tests; defaults to `readKeyringTokenState`. */
68
+ readState?: ((options?: {
69
+ force?: boolean | undefined;
70
+ }) => KeyringTokenState | Promise<KeyringTokenState>) | undefined;
71
+ }
72
+ export type KeepaliveReason = 'not-due' | 'no-expiry' | 'no-executable' | 'completed' | 'failed' | 'rejected' | 'not-rotated';
73
+ /**
74
+ * When to wake AGY up. AGY only rotates an EXPIRED token, so "renew early" would
75
+ * mean launching a process that provably does nothing; the caller keeps its
76
+ * check interval short instead and renewal fires on the last tick before the
77
+ * expiry. The window is still exposed as configuration because a user who sets a
78
+ * negative skew (renew after the moment it lapses) may want either side.
79
+ */
80
+ export declare const DEFAULT_KEEPALIVE_LEAD_MS: number;
81
+ export interface KeepaliveOutcome {
82
+ /** True when agy.exe was started because the token was close to expiry. */
83
+ refreshed: boolean;
84
+ reason: KeepaliveReason;
85
+ /** Seconds left on the old token when the refresh started. */
86
+ remainingSeconds?: number | undefined;
87
+ /** Epoch ms of the token as it stands after the attempt. */
88
+ expiresAtMs?: number | undefined;
89
+ /** Process exit code of the last attempt, when there was one. */
90
+ exitCode?: number | null | undefined;
91
+ /** Why the last attempt failed, for the panel banner. */
92
+ detail?: string | undefined;
93
+ }
94
+ export declare function refreshAgyTokenIfDue(options?: KeepaliveOptions): Promise<KeepaliveOutcome>;
95
+ export interface KeepaliveRunInput {
96
+ executable: string;
97
+ model?: string | undefined;
98
+ cwd?: string | undefined;
99
+ timeoutMs?: number | undefined;
100
+ run?: ((request: Parameters<typeof runProcess>[0]) => Promise<ProcessResult>) | undefined;
101
+ }
102
+ /** What the last renewal attempt actually did, including a rejection. */
103
+ export interface KeepaliveRunResult {
104
+ ok: boolean;
105
+ /** Process exit code; null when the process never started. */
106
+ exitCode: number | null;
107
+ /** First non-empty stderr line (or the spawn error), when the run failed. */
108
+ detail?: string | undefined;
109
+ }
110
+ /**
111
+ * Start the wake-up turn. Exported so the elapsed time in the log/panel can be
112
+ * attributed to a real process and so tests can assert the exact argv and stdin
113
+ * contract without a live AGY.
114
+ *
115
+ * No stdin frame: `--input-format stream-json` is not passed, so a frame would
116
+ * just be ignored. stdin is closed immediately instead, which is what stops the
117
+ * turn from streaming a reply and keeps the run at zero generated tokens.
118
+ *
119
+ * The exit code is checked, not just the absence of a spawn error. AGY rejects
120
+ * argument combinations with code 2 and a one-line stderr message; swallowing
121
+ * that made the first version of this module report success while the process
122
+ * had refused to run at all.
123
+ */
124
+ export declare function startAgyKeepaliveRun(input: KeepaliveRunInput): Promise<KeepaliveRunResult>;
125
+ /**
126
+ * Outcome of the most recent renewal attempt, for the status endpoint. A
127
+ * separate accessor keeps the decision function free of reporting concerns and
128
+ * lets the panel show a rejected argv even on ticks that are "not due".
129
+ */
130
+ export declare function lastKeepaliveOutcome(): {
131
+ reason: KeepaliveReason;
132
+ detail?: string | undefined;
133
+ } | undefined;
134
+ /**
135
+ * Epoch ms from a Credential Manager `LastWritten` FILETIME, for the web router.
136
+ * Only needed to tell "the keyring moved" from "it did not"; the expiry field
137
+ * inside the blob is the load-bearing reading.
138
+ */
139
+ export declare function filetimeToEpochMs(filetime: bigint | number | string): number | undefined;
140
+ //# sourceMappingURL=keepalive.d.ts.map
@@ -0,0 +1,445 @@
1
+ /**
2
+ * Keep the keyring OAuth token alive before it lapses.
3
+ *
4
+ * Why this exists: AGY stores its Google credential in the Windows Credential
5
+ * Manager entry `gemini:antigravity` with a hard `expiry` field. Once that
6
+ * moment passes, every quota RPC answered with 401 until some other program
7
+ * happened to start `agy.exe` and refresh it. The panel therefore showed
8
+ * "登录已过期" after the machine sat idle for an hour, even though the account
9
+ * itself was fine (observed 2026-10-22).
10
+ *
11
+ * The refresh itself cannot be done over HTTP here: rotating the token needs
12
+ * Google's OAuth client id/secret, which live inside the closed-source agy.exe.
13
+ * What CAN be done is start agy.exe ourselves early enough that its own
14
+ * "silent auth" path rotates the token, and hand it a run that produces as few
15
+ * billable tokens as possible.
16
+ *
17
+ * Two facts measured on this host shape the design (2026-10-22):
18
+ *
19
+ * 1. `agy <subcommand>` (e.g. `agy models`) prints `not authenticated, trying
20
+ * silent auth` and then only calls the token endpoint when the stored token
21
+ * is ALREADY expired — while it is merely close to expiry the subcommand
22
+ * succeeds without rotating anything. It is also useless as a wake-up: it
23
+ * skips `loadCodeAssist`, so it cannot be used to probe liveness.
24
+ * 2. `agy -p "." --effort low` DID rotate (keyring LastWritten moved 11:00:24,
25
+ * log `browser.go:265 token refreshed, new expiry=...`), but it completes a
26
+ * real assistant turn: three `streamGenerateContent` upstream calls, ~20 s,
27
+ * and those calls are part of what drains the weekly bucket this whole
28
+ * feature is trying to protect.
29
+ *
30
+ * So the cheap path is "close the request pipe immediately after the frame is
31
+ * written" (`stdinMode: 'close'`). AGY reads that EOF as "no next turn", shuts
32
+ * its store manager down and cancels the turn (the same reason the adapter's
33
+ * default is `hold`), but the silent-auth handshake runs BEFORE any generation
34
+ * begins — the token is rotated and no `streamGenerateContent` is issued.
35
+ * `startAgyProcess` makes that mode mandatory rather than a suggestion.
36
+ *
37
+ * 3. What "due" actually means, read off the logs: `keyring.go:64 keyringAuth:
38
+ * loaded token ... expired=` is followed by `browser.go:265 token refreshed,
39
+ * new expiry=now+3599s` ONLY when `expired=true`, and never twice in a row
40
+ * while it is false. Starting agy while the token still has life is
41
+ * therefore a pure no-op that costs a process launch every tick.
42
+ *
43
+ * So renewal happens on the LAST tick before expiry, not in a wide window that
44
+ * would only ever spin agy for nothing. The check that keeps running in the
45
+ * lead window is a write comparison, not a process launch.
46
+ */
47
+ import { execFile, execFileSync } from 'node:child_process';
48
+ import { resolveAgyExecutable, runProcess } from './process.js';
49
+ /** Windows Credential Manager target AGY stores its OAuth blob under. */
50
+ export const AGY_KEYRING_TARGET = 'gemini:antigravity';
51
+ /**
52
+ * Reads the SAME P/Invoke blob the web router reads, but inheriting the router's
53
+ * proven invocation shape: `-Command "& { <script> } <target>"`. Feeding the
54
+ * script through `-Command -` (stdin) silently executes nothing on this host —
55
+ * PowerShell parses the here-string and exits 0 with empty stdout, measured
56
+ * 2026-10-22. Duplicated rather than imported so the keep-alive path has no
57
+ * dependency on the HTTP layer.
58
+ */
59
+ const WIN_CRED_SCRIPT = `
60
+ param($target)
61
+ $sig = @'
62
+ using System;
63
+ using System.Runtime.InteropServices;
64
+ public class WinCredKeepalive {
65
+ [DllImport("advapi32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
66
+ public static extern bool CredRead(string target, int type, int flags, out IntPtr credential);
67
+ [DllImport("advapi32.dll", SetLastError=true)]
68
+ public static extern void CredFree(IntPtr cred);
69
+ [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
70
+ public struct CREDENTIAL {
71
+ public int Flags;
72
+ public int Type;
73
+ public string TargetName;
74
+ public string Comment;
75
+ public long LastWritten;
76
+ public int CredentialBlobSize;
77
+ public IntPtr CredentialBlob;
78
+ public int Persist;
79
+ public int AttributeCount;
80
+ public IntPtr Attributes;
81
+ public string TargetAlias;
82
+ public string UserName;
83
+ }
84
+ public static string Read(string target) {
85
+ IntPtr ptr;
86
+ if (CredRead(target, 1, 0, out ptr)) {
87
+ CREDENTIAL c = (CREDENTIAL)Marshal.PtrToStructure(ptr, typeof(CREDENTIAL));
88
+ byte[] b = new byte[c.CredentialBlobSize];
89
+ Marshal.Copy(c.CredentialBlob, b, 0, c.CredentialBlobSize);
90
+ CredFree(ptr);
91
+ return System.Convert.ToBase64String(b);
92
+ }
93
+ return null;
94
+ }
95
+ }
96
+ '@
97
+ Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue
98
+ [Console]::Out.Write([WinCredKeepalive]::Read($target))
99
+ `;
100
+ /** macOS/Linux read path: untouched, never part of the Windows hot path. */
101
+ function readKeyringTokenStateSyncImpl() {
102
+ if (process.platform !== 'win32')
103
+ return {};
104
+ try {
105
+ const b64 = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `& { ${WIN_CRED_SCRIPT} }`, AGY_KEYRING_TARGET], {
106
+ encoding: 'utf8',
107
+ timeout: 8000,
108
+ windowsHide: true,
109
+ stdio: ['ignore', 'pipe', 'ignore'],
110
+ }).trim();
111
+ if (!b64)
112
+ return {};
113
+ const parsed = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
114
+ const token = parsed?.token ?? {};
115
+ const raw = typeof token.expiry === 'string' ? token.expiry : undefined;
116
+ const parsedExpiry = raw === undefined ? Number.NaN : Date.parse(raw);
117
+ const expiresInSeconds = typeof token.expires_in === 'number' ? token.expires_in : 3599;
118
+ return Number.isFinite(parsedExpiry)
119
+ ? { expiresAtMs: parsedExpiry }
120
+ // A blob without `expiry` is not "expired": assume the standard lifetime
121
+ // so a reader quirk cannot trigger a refresh loop on every tick.
122
+ : { expiresAtMs: Date.now() + expiresInSeconds * 1000 };
123
+ }
124
+ catch {
125
+ return {};
126
+ }
127
+ }
128
+ /**
129
+ * Synchronous expiry read. Non-Windows only: on Windows every `execFileSync`
130
+ * spawn blocks the host's event loop for 0.2–3 s (measured 2026-10-23), which is
131
+ * what made `/status` take 10–22 s. Windows callers must use the async
132
+ * `readKeyringTokenState()` / `readKeyringBlob()` instead.
133
+ */
134
+ export function readKeyringTokenStateSync() {
135
+ return readKeyringTokenStateSyncImpl();
136
+ }
137
+ function decodeKeyringBlob(b64) {
138
+ const parsed = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
139
+ const accessToken = typeof parsed?.token?.access_token === 'string' ? parsed.token.access_token : null;
140
+ // `expiry` is the field agy.exe itself compares against before rotating the
141
+ // token, so it is the one reading the panel should trust: no id_token
142
+ // decoding, no guess from `expires_in`.
143
+ const rawExpiry = parsed?.token?.expiry;
144
+ const parsedExpiry = typeof rawExpiry === 'string' ? Date.parse(rawExpiry) : Number.NaN;
145
+ let email;
146
+ let name;
147
+ const idToken = parsed?.id_token;
148
+ if (typeof idToken === 'string') {
149
+ try {
150
+ const p1 = idToken.split('.')[1];
151
+ if (p1) {
152
+ const payload = JSON.parse(Buffer.from(p1, 'base64').toString('utf8'));
153
+ email = typeof payload?.email === 'string' ? payload.email : undefined;
154
+ name = typeof payload?.name === 'string' ? payload.name : undefined;
155
+ }
156
+ }
157
+ catch { }
158
+ }
159
+ return {
160
+ accessToken,
161
+ email,
162
+ name,
163
+ ...(Number.isFinite(parsedExpiry) ? { expiresAtMs: parsedExpiry } : {}),
164
+ };
165
+ }
166
+ // ---- async keyring reads (the only Windows path the web router may use) ----
167
+ // Why: `readIdentityFromKeyring` used to spawn Windows PowerShell synchronously
168
+ // and `/status` called it 2–3 times per request, so every settings RPC queued
169
+ // behind a blocked event loop. Reads are async now, single-flighted, and
170
+ // cached. The cache is a "last known value" (answers immediately even when
171
+ // stale) plus a 30 s freshness window: a renewal done by keepalive or by a
172
+ // normal message shows up without the panel paying another spawn.
173
+ const KEYRING_FRESH_MS = 30_000;
174
+ let cachedBlob;
175
+ let cachedBlobAt = 0;
176
+ let blobInFlight;
177
+ function blobIsFresh(now) {
178
+ return cachedBlob !== undefined && now - cachedBlobAt < KEYRING_FRESH_MS;
179
+ }
180
+ async function readKeyringBlobUncached() {
181
+ // Credential seam, checked before the platform gate. Why: there is no Windows
182
+ // Credential Manager on Linux/macOS (and none on a CI runner), so every chain
183
+ // behind this read — the quota panel, its 4 daily-host tests — could only ever
184
+ // be green on the one dev machine that happened to hold a real login. That is
185
+ // a fake green, not a passing suite. `DSH_AGY_KEYRING_BLOB` feeds the exact
186
+ // base64 OAuth blob agy.exe stores through the identical decode path, so a
187
+ // test (or an operator without a keyring) can stand in for the credential.
188
+ // Unset => behavior unchanged. Set => the blob is trusted as if read from the
189
+ // store, on every platform: whoever can set this env can already read that
190
+ // store as the same user, so it grants nothing new.
191
+ const injected = process.env.DSH_AGY_KEYRING_BLOB?.trim();
192
+ if (injected !== undefined && injected.length > 0) {
193
+ try {
194
+ return decodeKeyringBlob(injected);
195
+ }
196
+ catch {
197
+ // A malformed blob is "no credential", never a crash and never a partial
198
+ // identity: fail closed to the same shape the unreadable keyring gives.
199
+ return { accessToken: null };
200
+ }
201
+ }
202
+ if (process.platform !== 'win32')
203
+ return { accessToken: null };
204
+ return await new Promise((resolve) => {
205
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', `& { ${WIN_CRED_SCRIPT} }`, AGY_KEYRING_TARGET], { encoding: 'utf8', timeout: 8000, windowsHide: true }, (error, stdout) => {
206
+ const b64 = typeof stdout === 'string' ? stdout.trim() : '';
207
+ // A spawn failure or an unreadable blob must not be cached as "signed
208
+ // out": the caller falls back to the last known value instead.
209
+ if (error !== null || b64.length === 0) {
210
+ resolve({ accessToken: null });
211
+ return;
212
+ }
213
+ try {
214
+ resolve(decodeKeyringBlob(b64));
215
+ }
216
+ catch {
217
+ resolve({ accessToken: null });
218
+ }
219
+ });
220
+ });
221
+ }
222
+ /**
223
+ * Current keyring contents. Never throws and never blocks the event loop.
224
+ * `force` bypasses the freshness window (used after a renewal). A concurrent
225
+ * read joins the one already running unless the caller forced.
226
+ */
227
+ export async function readKeyringBlob(options = {}) {
228
+ const now = Date.now();
229
+ if (options.force !== true && blobIsFresh(now))
230
+ return cachedBlob;
231
+ if (blobInFlight !== undefined && options.force !== true)
232
+ return blobInFlight;
233
+ const run = readKeyringBlobUncached().then((blob) => {
234
+ if (blob.accessToken !== null) {
235
+ cachedBlob = blob;
236
+ cachedBlobAt = Date.now();
237
+ }
238
+ return blob;
239
+ });
240
+ blobInFlight = run;
241
+ try {
242
+ return await run;
243
+ }
244
+ finally {
245
+ if (blobInFlight === run)
246
+ blobInFlight = undefined;
247
+ }
248
+ }
249
+ /** Expiry-only view of `readKeyringBlob`, for the keep-alive decision. */
250
+ export async function readKeyringTokenState(options = {}) {
251
+ const blob = await readKeyringBlob(options);
252
+ return blob.expiresAtMs === undefined ? {} : { expiresAtMs: blob.expiresAtMs };
253
+ }
254
+ /**
255
+ * Arguments for the wake-up run. Deliberately minimal:
256
+ * - the prompt goes on argv (`-p .`). AGY refuses an empty prompt ("empty
257
+ * prompt. Usage: agy --print ...") and reads a single "." as "look at the
258
+ * working directory", which is the cheapest turn that still authenticates.
259
+ * The adapter's `-p ''` + stdin-frame shape cannot be reused here: AGY exits
260
+ * with code 2 and `--input-format stream-json reads prompts from stdin, so a
261
+ * prompt given on the command line would be ignored` — measured 2026-10-22,
262
+ * and that rejection was silent enough to make the first version look like a
263
+ * working renewal that never rotated anything.
264
+ * - no `--input-format` / `--output-format`: they only matter for framing a
265
+ * stream we are not reading; leaving them out also avoids the conflict above.
266
+ * - `--effort low` is passed anyway: some gemini-* ids refuse to start without
267
+ * an `--effort` flag, and if the cancellation ever loses the race, the turn
268
+ * that runs is the cheapest one available instead of the 'high' default.
269
+ * - no `--agent`: this must not depend on a template being installed.
270
+ * - `--disable-slash-commands`: a bare "." must never be parsed as a command.
271
+ */
272
+ export function buildKeepaliveArgs(model) {
273
+ return [
274
+ '-p', '.',
275
+ ...(model === undefined || model.trim().length === 0 ? [] : ['--model', model.trim()]),
276
+ '--effort', 'low',
277
+ '--disable-slash-commands',
278
+ ];
279
+ }
280
+ /**
281
+ * When to wake AGY up. AGY only rotates an EXPIRED token, so "renew early" would
282
+ * mean launching a process that provably does nothing; the caller keeps its
283
+ * check interval short instead and renewal fires on the last tick before the
284
+ * expiry. The window is still exposed as configuration because a user who sets a
285
+ * negative skew (renew after the moment it lapses) may want either side.
286
+ */
287
+ export const DEFAULT_KEEPALIVE_LEAD_MS = 60 * 1000;
288
+ /** Expiry value of the last renewal attempt that did not rotate, if any. */
289
+ let lastFailedExpiry;
290
+ /** Why that attempt gave up, so a deduplicated tick still reports the cause. */
291
+ let lastFailedReason;
292
+ let lastFailedDetail;
293
+ function rememberFailure(expiresAtMs, reason, detail) {
294
+ lastFailedExpiry = expiresAtMs;
295
+ lastFailedReason = reason;
296
+ lastFailedDetail = detail;
297
+ }
298
+ function forgetFailure() {
299
+ lastFailedExpiry = undefined;
300
+ lastFailedReason = undefined;
301
+ lastFailedDetail = undefined;
302
+ }
303
+ export async function refreshAgyTokenIfDue(options = {}) {
304
+ const readState = options.readState ?? readKeyringTokenState;
305
+ const leadMs = options.leadMs ?? DEFAULT_KEEPALIVE_LEAD_MS;
306
+ const before = await readState();
307
+ if (before.expiresAtMs === undefined)
308
+ return { refreshed: false, reason: 'no-expiry' };
309
+ const remainingMs = before.expiresAtMs - Date.now();
310
+ const remainingSeconds = Math.round(remainingMs / 1000);
311
+ if (remainingMs > leadMs) {
312
+ return { refreshed: false, reason: 'not-due', remainingSeconds, expiresAtMs: before.expiresAtMs };
313
+ }
314
+ // Renewal must never be a side effect of "somebody looked at the panel" or of
315
+ // a timer that keeps ticking: a failing attempt would otherwise restart
316
+ // agy.exe every few minutes. Module-level on purpose — remember the expiry we
317
+ // last gave up on and stay quiet until the keyring actually moves. The reason
318
+ // keeps the original failure so the panel can still say WHY it stopped.
319
+ if (before.expiresAtMs === lastFailedExpiry) {
320
+ return {
321
+ refreshed: false,
322
+ reason: lastFailedReason ?? 'not-due',
323
+ remainingSeconds,
324
+ expiresAtMs: before.expiresAtMs,
325
+ ...(lastFailedDetail === undefined ? {} : { detail: lastFailedDetail }),
326
+ };
327
+ }
328
+ const executable = resolveAgyExecutable(options.executable);
329
+ if (executable.trim().length === 0) {
330
+ return { refreshed: false, reason: 'no-executable', remainingSeconds, expiresAtMs: before.expiresAtMs };
331
+ }
332
+ const started = await startAgyKeepaliveRun({
333
+ executable,
334
+ ...(options.model === undefined ? {} : { model: options.model }),
335
+ ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
336
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
337
+ ...(options.run === undefined ? {} : { run: options.run }),
338
+ });
339
+ if (!started.ok) {
340
+ // Spawn failures and argument rejections are different animals: a spawn
341
+ // failure is the environment, a rejection is our own argv. Report them
342
+ // separately so the panel can say which one happened.
343
+ const rejected = started.exitCode !== null && started.exitCode !== 0;
344
+ const reason = rejected ? 'rejected' : 'failed';
345
+ rememberFailure(before.expiresAtMs, reason, started.detail);
346
+ return {
347
+ refreshed: false,
348
+ reason,
349
+ remainingSeconds,
350
+ expiresAtMs: before.expiresAtMs,
351
+ exitCode: started.exitCode,
352
+ ...(started.detail === undefined ? {} : { detail: started.detail }),
353
+ };
354
+ }
355
+ // Forced: `before` (and any earlier caller) just populated the 30 s freshness
356
+ // window, so a plain read would hand back the very expiry we are trying to
357
+ // compare against. A renewal is only ever judged on what the keyring says
358
+ // now, never on a snapshot taken before agy.exe ran.
359
+ const after = await readState({ force: true });
360
+ const expiresAtMs = after.expiresAtMs ?? before.expiresAtMs;
361
+ // A run that left the expiry untouched is a failure for our purposes even
362
+ // though the process exited 0 — agy saw a token it considered valid, or the
363
+ // network refused to rotate. Either way: do not relaunch on the same expiry.
364
+ // Anything newer counts, including a credential that was replaced with one of
365
+ // the same remaining lifetime.
366
+ const rotated = expiresAtMs !== before.expiresAtMs;
367
+ if (rotated) {
368
+ forgetFailure();
369
+ }
370
+ else {
371
+ rememberFailure(before.expiresAtMs, 'not-rotated', 'agy exited 0 but the keyring expiry did not move');
372
+ }
373
+ return {
374
+ refreshed: true,
375
+ reason: rotated ? 'completed' : 'not-rotated',
376
+ remainingSeconds,
377
+ expiresAtMs,
378
+ exitCode: started.exitCode,
379
+ ...(rotated ? {} : { detail: 'agy exited 0 but the keyring expiry did not move' }),
380
+ };
381
+ }
382
+ /**
383
+ * Start the wake-up turn. Exported so the elapsed time in the log/panel can be
384
+ * attributed to a real process and so tests can assert the exact argv and stdin
385
+ * contract without a live AGY.
386
+ *
387
+ * No stdin frame: `--input-format stream-json` is not passed, so a frame would
388
+ * just be ignored. stdin is closed immediately instead, which is what stops the
389
+ * turn from streaming a reply and keeps the run at zero generated tokens.
390
+ *
391
+ * The exit code is checked, not just the absence of a spawn error. AGY rejects
392
+ * argument combinations with code 2 and a one-line stderr message; swallowing
393
+ * that made the first version of this module report success while the process
394
+ * had refused to run at all.
395
+ */
396
+ export async function startAgyKeepaliveRun(input) {
397
+ const run = input.run ?? runProcess;
398
+ const home = input.cwd ?? process.env.USERPROFILE ?? process.env.HOME;
399
+ try {
400
+ const result = await run({
401
+ executable: input.executable,
402
+ args: buildKeepaliveArgs(input.model),
403
+ stdinMode: 'close',
404
+ ...(home === undefined ? {} : { cwd: home }),
405
+ timeoutMs: input.timeoutMs ?? 45_000,
406
+ });
407
+ const code = typeof result?.exitCode === 'number' ? result.exitCode : 0;
408
+ if (code !== 0) {
409
+ return { ok: false, exitCode: code, detail: firstLine(String(result?.stderr ?? '')) };
410
+ }
411
+ return { ok: true, exitCode: code };
412
+ }
413
+ catch (error) {
414
+ const message = error instanceof Error ? error.message : String(error);
415
+ return { ok: false, exitCode: null, detail: firstLine(message) };
416
+ }
417
+ }
418
+ function firstLine(text, limit = 180) {
419
+ const line = text.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0);
420
+ if (line === undefined)
421
+ return undefined;
422
+ return line.length > limit ? `${line.slice(0, limit)}…` : line;
423
+ }
424
+ /**
425
+ * Outcome of the most recent renewal attempt, for the status endpoint. A
426
+ * separate accessor keeps the decision function free of reporting concerns and
427
+ * lets the panel show a rejected argv even on ticks that are "not due".
428
+ */
429
+ export function lastKeepaliveOutcome() {
430
+ if (lastFailedReason === undefined)
431
+ return undefined;
432
+ return { reason: lastFailedReason, ...(lastFailedDetail === undefined ? {} : { detail: lastFailedDetail }) };
433
+ }
434
+ /**
435
+ * Epoch ms from a Credential Manager `LastWritten` FILETIME, for the web router.
436
+ * Only needed to tell "the keyring moved" from "it did not"; the expiry field
437
+ * inside the blob is the load-bearing reading.
438
+ */
439
+ export function filetimeToEpochMs(filetime) {
440
+ const value = typeof filetime === 'bigint' ? filetime : BigInt(String(filetime));
441
+ if (value === 0n)
442
+ return undefined;
443
+ return Number(value / 10000n - 11644473600000n);
444
+ }
445
+ //# sourceMappingURL=keepalive.js.map
@@ -0,0 +1,32 @@
1
+ export type AgyQueueErrorCode = 'QUEUE_FULL' | 'QUEUE_TIMEOUT' | 'ABORTED';
2
+ export declare class AgyQueueError extends Error {
3
+ readonly code: AgyQueueErrorCode;
4
+ constructor(message: string, code: AgyQueueErrorCode, options?: ErrorOptions);
5
+ }
6
+ export interface AgyConcurrencyOptions {
7
+ maxConcurrent: number;
8
+ maxQueue: number;
9
+ queueTimeoutMs: number;
10
+ }
11
+ export interface AgyConcurrencyStats extends AgyConcurrencyOptions {
12
+ active: number;
13
+ queued: number;
14
+ }
15
+ type Release = () => void;
16
+ /** FIFO limiter for AGY child processes, with bounded queue and cancellation. */
17
+ export declare class AgyConcurrencyLimiter {
18
+ private readonly maxConcurrent;
19
+ private readonly maxQueue;
20
+ private readonly queueTimeoutMs;
21
+ private active;
22
+ private readonly queue;
23
+ constructor(options: AgyConcurrencyOptions);
24
+ getStats(): AgyConcurrencyStats;
25
+ acquire(signal?: AbortSignal): Promise<Release>;
26
+ private createRelease;
27
+ private rejectEntry;
28
+ private resolveEntry;
29
+ private drain;
30
+ }
31
+ export {};
32
+ //# sourceMappingURL=limiter.d.ts.map