@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.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 (128) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +7 -7
  3. package/scripts/format-prompts.ts +1 -1
  4. package/src/cli/args.ts +2 -2
  5. package/src/cli.ts +1 -0
  6. package/src/commands/acp.ts +24 -0
  7. package/src/commands/launch.ts +6 -4
  8. package/src/commit/agentic/prompts/system.md +1 -1
  9. package/src/config/model-resolver.ts +30 -0
  10. package/src/config/settings-schema.ts +31 -0
  11. package/src/edit/index.ts +22 -1
  12. package/src/edit/modes/patch.ts +10 -0
  13. package/src/edit/modes/replace.ts +3 -0
  14. package/src/edit/renderer.ts +10 -0
  15. package/src/eval/js/context-manager.ts +1 -1
  16. package/src/eval/js/shared/rewrite-imports.ts +120 -48
  17. package/src/eval/js/shared/runtime.ts +31 -4
  18. package/src/eval/js/tool-bridge.ts +43 -21
  19. package/src/extensibility/extensions/runner.ts +54 -1
  20. package/src/extensibility/extensions/types.ts +11 -0
  21. package/src/extensibility/skills.ts +33 -1
  22. package/src/internal-urls/docs-index.generated.ts +6 -6
  23. package/src/internal-urls/index.ts +1 -0
  24. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  25. package/src/internal-urls/router.ts +6 -3
  26. package/src/internal-urls/types.ts +22 -1
  27. package/src/main.ts +13 -9
  28. package/src/modes/acp/acp-agent.ts +361 -54
  29. package/src/modes/acp/acp-client-bridge.ts +152 -0
  30. package/src/modes/acp/acp-event-mapper.ts +180 -15
  31. package/src/modes/acp/terminal-auth.ts +37 -0
  32. package/src/modes/components/read-tool-group.ts +29 -1
  33. package/src/modes/controllers/command-controller.ts +14 -6
  34. package/src/modes/controllers/event-controller.ts +24 -11
  35. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  36. package/src/modes/controllers/input-controller.ts +72 -39
  37. package/src/modes/interactive-mode.ts +71 -7
  38. package/src/modes/rpc/rpc-mode.ts +17 -2
  39. package/src/modes/types.ts +6 -2
  40. package/src/modes/utils/ui-helpers.ts +15 -3
  41. package/src/prompts/agents/designer.md +5 -5
  42. package/src/prompts/agents/explore.md +7 -7
  43. package/src/prompts/agents/init.md +9 -9
  44. package/src/prompts/agents/librarian.md +14 -14
  45. package/src/prompts/agents/plan.md +4 -4
  46. package/src/prompts/agents/reviewer.md +5 -5
  47. package/src/prompts/agents/task.md +10 -10
  48. package/src/prompts/commands/orchestrate.md +2 -2
  49. package/src/prompts/compaction/branch-summary.md +3 -3
  50. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  51. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  52. package/src/prompts/compaction/compaction-summary.md +5 -5
  53. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  54. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  55. package/src/prompts/memories/consolidation.md +2 -2
  56. package/src/prompts/memories/read-path.md +1 -1
  57. package/src/prompts/memories/stage_one_input.md +1 -1
  58. package/src/prompts/memories/stage_one_system.md +5 -5
  59. package/src/prompts/review-request.md +4 -4
  60. package/src/prompts/system/agent-creation-architect.md +17 -17
  61. package/src/prompts/system/agent-creation-user.md +2 -2
  62. package/src/prompts/system/commit-message-system.md +2 -2
  63. package/src/prompts/system/custom-system-prompt.md +2 -2
  64. package/src/prompts/system/eager-todo.md +6 -6
  65. package/src/prompts/system/handoff-document.md +1 -1
  66. package/src/prompts/system/plan-mode-active.md +22 -21
  67. package/src/prompts/system/plan-mode-approved.md +4 -4
  68. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  69. package/src/prompts/system/plan-mode-reference.md +2 -2
  70. package/src/prompts/system/plan-mode-subagent.md +8 -8
  71. package/src/prompts/system/plan-mode-tool-decision-reminder.md +2 -2
  72. package/src/prompts/system/project-prompt.md +4 -4
  73. package/src/prompts/system/subagent-system-prompt.md +7 -7
  74. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  75. package/src/prompts/system/system-prompt.md +72 -71
  76. package/src/prompts/system/ttsr-interrupt.md +1 -1
  77. package/src/prompts/tools/apply-patch.md +1 -1
  78. package/src/prompts/tools/ast-edit.md +3 -3
  79. package/src/prompts/tools/ast-grep.md +3 -3
  80. package/src/prompts/tools/browser.md +3 -3
  81. package/src/prompts/tools/checkpoint.md +3 -3
  82. package/src/prompts/tools/exit-plan-mode.md +2 -2
  83. package/src/prompts/tools/find.md +3 -3
  84. package/src/prompts/tools/github.md +2 -5
  85. package/src/prompts/tools/hashline.md +6 -6
  86. package/src/prompts/tools/image-gen.md +3 -3
  87. package/src/prompts/tools/irc.md +1 -1
  88. package/src/prompts/tools/lsp.md +2 -2
  89. package/src/prompts/tools/patch.md +6 -6
  90. package/src/prompts/tools/read.md +7 -7
  91. package/src/prompts/tools/replace.md +5 -5
  92. package/src/prompts/tools/retain.md +1 -1
  93. package/src/prompts/tools/rewind.md +2 -2
  94. package/src/prompts/tools/search.md +2 -2
  95. package/src/prompts/tools/ssh.md +2 -2
  96. package/src/prompts/tools/task.md +12 -6
  97. package/src/prompts/tools/web-search.md +2 -2
  98. package/src/prompts/tools/write.md +3 -3
  99. package/src/sdk.ts +69 -12
  100. package/src/session/agent-session.ts +231 -22
  101. package/src/session/client-bridge.ts +81 -0
  102. package/src/session/compaction/errors.ts +31 -0
  103. package/src/session/compaction/index.ts +1 -0
  104. package/src/slash-commands/acp-builtins.ts +46 -0
  105. package/src/slash-commands/builtin-registry.ts +699 -116
  106. package/src/slash-commands/helpers/context-report.ts +39 -0
  107. package/src/slash-commands/helpers/format.ts +23 -0
  108. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  109. package/src/slash-commands/helpers/mcp.ts +532 -0
  110. package/src/slash-commands/helpers/parse.ts +85 -0
  111. package/src/slash-commands/helpers/ssh.ts +193 -0
  112. package/src/slash-commands/helpers/todo.ts +279 -0
  113. package/src/slash-commands/helpers/usage-report.ts +91 -0
  114. package/src/slash-commands/types.ts +126 -0
  115. package/src/task/executor.ts +10 -3
  116. package/src/task/index.ts +17 -1
  117. package/src/task/render.ts +6 -3
  118. package/src/tools/bash.ts +176 -2
  119. package/src/tools/conflict-detect.ts +6 -6
  120. package/src/tools/fetch.ts +15 -4
  121. package/src/tools/find.ts +19 -1
  122. package/src/tools/gh-renderer.ts +0 -12
  123. package/src/tools/gh.ts +682 -176
  124. package/src/tools/github-cache.ts +548 -0
  125. package/src/tools/index.ts +3 -0
  126. package/src/tools/read.ts +110 -27
  127. package/src/tools/write.ts +23 -1
  128. package/src/tui/code-cell.ts +70 -2
@@ -0,0 +1,548 @@
1
+ /**
2
+ * SQLite-backed cache for rendered `github` issue/PR view output, plus a
3
+ * generic cache-aware wrapper that the tool ops and the `issue://`/`pr://`
4
+ * protocol handlers share.
5
+ *
6
+ * Storage:
7
+ * One process-wide connection opens lazily on first hit and stays open. All
8
+ * helpers swallow open/IO failures and degrade to "no cache" so a corrupt or
9
+ * unreadable DB never blocks a `gh` call.
10
+ *
11
+ * TTL:
12
+ * Soft TTL → return cached row directly.
13
+ * Past soft TTL but within hard TTL → return cached row AND schedule a
14
+ * background refresh (errors logged, never thrown).
15
+ * Past hard TTL → treat as miss and fetch fresh.
16
+ */
17
+
18
+ import { Database } from "bun:sqlite";
19
+ import * as fs from "node:fs";
20
+ import * as os from "node:os";
21
+ import * as path from "node:path";
22
+ import { getGithubCacheDbPath, logger } from "@oh-my-pi/pi-utils";
23
+ import type { Settings } from "../config/settings";
24
+
25
+ // ────────────────────────────────────────────────────────────────────────────
26
+ // Storage layer
27
+ // ────────────────────────────────────────────────────────────────────────────
28
+
29
+ export type CacheKind = "issue" | "pr" | "pr-diff";
30
+
31
+ const DEFAULT_CACHE_AUTH_KEY = "default";
32
+
33
+ export interface CachedView<T = unknown> {
34
+ authKey: string;
35
+ repo: string;
36
+ kind: CacheKind;
37
+ number: number;
38
+ includeComments: boolean;
39
+ fetchedAt: number;
40
+ payload: T;
41
+ rendered: string;
42
+ sourceUrl: string | undefined;
43
+ }
44
+
45
+ interface Row {
46
+ auth_key: string;
47
+ repo: string;
48
+ kind: CacheKind;
49
+ number: number;
50
+ include_comments: number;
51
+ fetched_at: number;
52
+ payload: string;
53
+ rendered: string;
54
+ source_url: string | null;
55
+ }
56
+
57
+ const DEFAULT_SOFT_TTL_SEC = 300; // 5 minutes
58
+ const DEFAULT_HARD_TTL_SEC = 60 * 60 * 24 * 7; // 7 days
59
+
60
+ let cachedDb: Database | null = null;
61
+ let openAttempted = false;
62
+
63
+ function ensureParentDir(filePath: string): void {
64
+ try {
65
+ const dir = path.dirname(filePath);
66
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ } catch (err) {
68
+ logger.debug("github cache: failed to create private parent dir", { err: String(err) });
69
+ }
70
+ }
71
+
72
+ function chmodIfExists(filePath: string, mode: number): void {
73
+ try {
74
+ fs.chmodSync(filePath, mode);
75
+ } catch (err) {
76
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
77
+ logger.debug("github cache: chmod failed", { err: String(err), path: filePath });
78
+ }
79
+ }
80
+ }
81
+
82
+ function protectDbFiles(dbPath: string): void {
83
+ chmodIfExists(dbPath, 0o600);
84
+ chmodIfExists(`${dbPath}-wal`, 0o600);
85
+ chmodIfExists(`${dbPath}-shm`, 0o600);
86
+ }
87
+
88
+ export function openDb(): Database | null {
89
+ if (cachedDb) return cachedDb;
90
+ if (openAttempted) return null;
91
+ openAttempted = true;
92
+ try {
93
+ const dbPath = getGithubCacheDbPath();
94
+ ensureParentDir(dbPath);
95
+ const db = new Database(dbPath);
96
+ db.run(`
97
+ PRAGMA journal_mode=WAL;
98
+ PRAGMA synchronous=NORMAL;
99
+ PRAGMA busy_timeout=5000;
100
+ `);
101
+ // Migrate any pre-existing table whose key/check constraint predates
102
+ // the current schema. The cache is regenerable, so we drop rows rather
103
+ // than running an in-place ALTER dance.
104
+ const userVersion = (db.prepare("PRAGMA user_version").get() as { user_version?: number } | undefined)
105
+ ?.user_version;
106
+ if (userVersion !== undefined && userVersion < 3) {
107
+ db.run("DROP TABLE IF EXISTS github_view_cache");
108
+ }
109
+ db.run(`
110
+ CREATE TABLE IF NOT EXISTS github_view_cache (
111
+ auth_key TEXT NOT NULL,
112
+ repo TEXT NOT NULL,
113
+ kind TEXT NOT NULL CHECK (kind IN ('issue','pr','pr-diff')),
114
+ number INTEGER NOT NULL,
115
+ include_comments INTEGER NOT NULL,
116
+ fetched_at INTEGER NOT NULL,
117
+ payload TEXT NOT NULL,
118
+ rendered TEXT NOT NULL,
119
+ source_url TEXT,
120
+ PRIMARY KEY (auth_key, repo, kind, number, include_comments)
121
+ );
122
+ CREATE INDEX IF NOT EXISTS idx_github_view_cache_fetched ON github_view_cache(fetched_at);
123
+ PRAGMA user_version = 3;
124
+ `);
125
+ protectDbFiles(dbPath);
126
+ cachedDb = db;
127
+ // No eviction on open: the default `DEFAULT_HARD_TTL_SEC` is a coarse
128
+ // backstop that runs before user settings load, so applying it here
129
+ // would nuke rows still valid under a stricter-or-laxer configured
130
+ // `github.cache.hardTtlSec`. The per-lookup `sweepIfDue()` in
131
+ // `getOrFetchView()` enforces the *configured* retention instead.
132
+ return db;
133
+ } catch (err) {
134
+ logger.warn("github cache: failed to open DB; cache disabled", { err: String(err) });
135
+ return null;
136
+ }
137
+ }
138
+
139
+ function evictExpired(db: Database, hardTtlMs: number): void {
140
+ try {
141
+ const cutoff = Date.now() - hardTtlMs;
142
+ db.prepare("DELETE FROM github_view_cache WHERE fetched_at < ?").run(cutoff);
143
+ } catch (err) {
144
+ logger.debug("github cache: eviction failed", { err: String(err) });
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Throttle for the per-lookup configured-TTL sweep. We don't want every
150
+ * cached read to issue a DELETE; once per `SWEEP_INTERVAL_MS` is enough to
151
+ * cap the on-disk exposure window at roughly `hardTtlMs + SWEEP_INTERVAL_MS`.
152
+ */
153
+ const SWEEP_INTERVAL_MS = 60_000;
154
+ let lastSweepAt = 0;
155
+
156
+ function sweepIfDue(hardTtlMs: number): void {
157
+ const now = Date.now();
158
+ if (now - lastSweepAt < SWEEP_INTERVAL_MS) return;
159
+ const db = openDb();
160
+ if (!db) return;
161
+ lastSweepAt = now;
162
+ evictExpired(db, hardTtlMs);
163
+ }
164
+
165
+ function getGhConfigDir(): string {
166
+ const override = process.env.GH_CONFIG_DIR;
167
+ if (override) return override;
168
+ const xdg = process.env.XDG_CONFIG_HOME;
169
+ if (xdg) return path.join(xdg, "gh");
170
+ return path.join(os.homedir(), ".config", "gh");
171
+ }
172
+
173
+ function hashCacheIdentity(parts: string[]): string {
174
+ return Bun.hash(parts.map(part => `${part.length}:${part}`).join("|")).toString(36);
175
+ }
176
+
177
+ /**
178
+ * Best-effort local fingerprint for the active GitHub CLI credentials.
179
+ *
180
+ * Cache hits must not cross account/token boundaries, but doing a `gh api user`
181
+ * probe before every cached read would defeat the soft-TTL contract that cache
182
+ * hits avoid a gh round-trip. Instead, key rows by credential material that the
183
+ * GitHub CLI itself consumes: token environment variables and/or hosts.yml.
184
+ * The DB stores only a hash, never the token or hosts.yml contents. If no
185
+ * credential source is visible, callers should pass `null` to bypass caching.
186
+ */
187
+ export function resolveGithubCacheAuthKey(host: string = process.env.GH_HOST || "github.com"): string | undefined {
188
+ const parts: string[] = [`host:${host}`];
189
+ let hasCredentialMaterial = false;
190
+ for (const name of ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"]) {
191
+ const value = process.env[name];
192
+ if (!value) continue;
193
+ hasCredentialMaterial = true;
194
+ parts.push(`${name}:${value}`);
195
+ }
196
+ try {
197
+ const hostsPath = path.join(getGhConfigDir(), "hosts.yml");
198
+ const hosts = fs.readFileSync(hostsPath, "utf8");
199
+ hasCredentialMaterial = true;
200
+ parts.push(`hosts:${hosts}`);
201
+ } catch (err) {
202
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
203
+ logger.debug("github cache: failed to read gh hosts config for cache identity", { err: String(err) });
204
+ }
205
+ }
206
+ if (!hasCredentialMaterial) return undefined;
207
+ return `${host}:${hashCacheIdentity(parts)}`;
208
+ }
209
+
210
+ function normalizeRepo(repo: string): string {
211
+ return repo.toLowerCase();
212
+ }
213
+
214
+ export function getCached<T = unknown>(
215
+ repo: string,
216
+ kind: CacheKind,
217
+ number: number,
218
+ includeComments: boolean,
219
+ authKey: string = DEFAULT_CACHE_AUTH_KEY,
220
+ ): CachedView<T> | null {
221
+ const db = openDb();
222
+ if (!db) return null;
223
+ try {
224
+ const row = db
225
+ .prepare(
226
+ "SELECT auth_key, repo, kind, number, include_comments, fetched_at, payload, rendered, source_url FROM github_view_cache WHERE auth_key = ? AND repo = ? AND kind = ? AND number = ? AND include_comments = ?",
227
+ )
228
+ .get(authKey, normalizeRepo(repo), kind, number, includeComments ? 1 : 0) as Row | undefined;
229
+ if (!row) return null;
230
+ let payload: T;
231
+ try {
232
+ payload = JSON.parse(row.payload) as T;
233
+ } catch (err) {
234
+ logger.debug("github cache: corrupt payload row, ignoring", { err: String(err), repo, kind, number });
235
+ return null;
236
+ }
237
+ return {
238
+ authKey: row.auth_key,
239
+ repo: row.repo,
240
+ kind: row.kind,
241
+ number: row.number,
242
+ includeComments: row.include_comments === 1,
243
+ fetchedAt: row.fetched_at,
244
+ payload,
245
+ rendered: row.rendered,
246
+ sourceUrl: row.source_url ?? undefined,
247
+ };
248
+ } catch (err) {
249
+ logger.debug("github cache: read failed", { err: String(err) });
250
+ return null;
251
+ }
252
+ }
253
+
254
+ export interface PutCachedInput<T = unknown> {
255
+ authKey?: string;
256
+ repo: string;
257
+ kind: CacheKind;
258
+ number: number;
259
+ includeComments: boolean;
260
+ payload: T;
261
+ rendered: string;
262
+ sourceUrl?: string;
263
+ fetchedAt?: number;
264
+ }
265
+
266
+ export function putCached<T = unknown>(input: PutCachedInput<T>): void {
267
+ const db = openDb();
268
+ if (!db) return;
269
+ try {
270
+ const fetchedAt = input.fetchedAt ?? Date.now();
271
+ const payloadJson = JSON.stringify(input.payload);
272
+ db.prepare(
273
+ "INSERT OR REPLACE INTO github_view_cache (auth_key, repo, kind, number, include_comments, fetched_at, payload, rendered, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
274
+ ).run(
275
+ input.authKey ?? DEFAULT_CACHE_AUTH_KEY,
276
+ normalizeRepo(input.repo),
277
+ input.kind,
278
+ input.number,
279
+ input.includeComments ? 1 : 0,
280
+ fetchedAt,
281
+ payloadJson,
282
+ input.rendered,
283
+ input.sourceUrl ?? null,
284
+ );
285
+ protectDbFiles(getGithubCacheDbPath());
286
+ } catch (err) {
287
+ logger.debug("github cache: write failed", { err: String(err) });
288
+ }
289
+ }
290
+
291
+ /** Drop a specific cache entry. */
292
+ export function invalidate(
293
+ repo: string,
294
+ kind: CacheKind,
295
+ number: number,
296
+ includeComments?: boolean,
297
+ authKey: string = DEFAULT_CACHE_AUTH_KEY,
298
+ ): void {
299
+ const db = openDb();
300
+ if (!db) return;
301
+ try {
302
+ if (includeComments === undefined) {
303
+ db.prepare("DELETE FROM github_view_cache WHERE auth_key = ? AND repo = ? AND kind = ? AND number = ?").run(
304
+ authKey,
305
+ normalizeRepo(repo),
306
+ kind,
307
+ number,
308
+ );
309
+ } else {
310
+ db.prepare(
311
+ "DELETE FROM github_view_cache WHERE auth_key = ? AND repo = ? AND kind = ? AND number = ? AND include_comments = ?",
312
+ ).run(authKey, normalizeRepo(repo), kind, number, includeComments ? 1 : 0);
313
+ }
314
+ } catch (err) {
315
+ logger.debug("github cache: invalidate failed", { err: String(err) });
316
+ }
317
+ }
318
+
319
+ /** Drop every cached row. Test helper. */
320
+ export function clearAll(): void {
321
+ const db = openDb();
322
+ if (!db) return;
323
+ try {
324
+ db.prepare("DELETE FROM github_view_cache").run();
325
+ } catch (err) {
326
+ logger.debug("github cache: clear failed", { err: String(err) });
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Test/maintenance helper. Closes and forgets the cached connection so the
332
+ * next access reopens against (possibly) a different DB path.
333
+ */
334
+ export function resetForTests(): void {
335
+ if (cachedDb) {
336
+ try {
337
+ cachedDb.close();
338
+ } catch {
339
+ // Closing failures are non-fatal.
340
+ }
341
+ }
342
+ cachedDb = null;
343
+ openAttempted = false;
344
+ lastSweepAt = 0;
345
+ }
346
+
347
+ // ────────────────────────────────────────────────────────────────────────────
348
+ // Cache-aware lookup wrapper
349
+ // ────────────────────────────────────────────────────────────────────────────
350
+
351
+ export interface FreshResult<T> {
352
+ rendered: string;
353
+ sourceUrl: string | undefined;
354
+ payload: T;
355
+ }
356
+
357
+ export interface CacheLookupOptions<T> {
358
+ repo: string;
359
+ kind: CacheKind;
360
+ number: number;
361
+ includeComments: boolean;
362
+ /**
363
+ * Auth/credential namespace for cache rows. Omit only in storage-layer
364
+ * tests; pass `null` when production code cannot determine an identity and
365
+ * must bypass persistent cache reads/writes.
366
+ */
367
+ authKey?: string | null;
368
+ fetchFresh: () => Promise<FreshResult<T>>;
369
+ settings?: Settings | undefined;
370
+ now?: number;
371
+ }
372
+
373
+ export type CacheStatus = "miss" | "fresh" | "stale" | "disabled";
374
+
375
+ export interface CacheLookupResult<T> {
376
+ rendered: string;
377
+ sourceUrl: string | undefined;
378
+ payload: T;
379
+ status: CacheStatus;
380
+ fetchedAt: number;
381
+ }
382
+
383
+ function readNumberSetting(settings: Settings | undefined, key: string, fallback: number): number {
384
+ if (!settings) return fallback;
385
+ try {
386
+ const value = (settings as unknown as { get(k: string): unknown }).get(key);
387
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value;
388
+ } catch {
389
+ // Unknown setting paths fall through to default; settings may be a
390
+ // stripped test stub that doesn't expose every key.
391
+ }
392
+ return fallback;
393
+ }
394
+
395
+ function readBooleanSetting(settings: Settings | undefined, key: string, fallback: boolean): boolean {
396
+ if (!settings) return fallback;
397
+ try {
398
+ const value = (settings as unknown as { get(k: string): unknown }).get(key);
399
+ if (typeof value === "boolean") return value;
400
+ } catch {
401
+ // Same fallback rationale as readNumberSetting.
402
+ }
403
+ return fallback;
404
+ }
405
+
406
+ export interface CacheTtl {
407
+ softMs: number;
408
+ hardMs: number;
409
+ enabled: boolean;
410
+ }
411
+
412
+ export function resolveCacheTtl(settings?: Settings): CacheTtl {
413
+ const softSec = readNumberSetting(settings, "github.cache.softTtlSec", DEFAULT_SOFT_TTL_SEC);
414
+ const hardSec = readNumberSetting(settings, "github.cache.hardTtlSec", DEFAULT_HARD_TTL_SEC);
415
+ const enabled = readBooleanSetting(settings, "github.cache.enabled", true);
416
+ return {
417
+ softMs: Math.max(0, softSec) * 1000,
418
+ hardMs: Math.max(0, hardSec) * 1000,
419
+ enabled,
420
+ };
421
+ }
422
+
423
+ function storeResult<T>(
424
+ authKey: string,
425
+ repo: string,
426
+ kind: CacheKind,
427
+ number: number,
428
+ includeComments: boolean,
429
+ result: FreshResult<T>,
430
+ fetchedAt: number,
431
+ ): void {
432
+ putCached<T>({
433
+ authKey,
434
+ repo,
435
+ kind,
436
+ number,
437
+ includeComments,
438
+ payload: result.payload,
439
+ rendered: result.rendered,
440
+ sourceUrl: result.sourceUrl,
441
+ fetchedAt,
442
+ });
443
+ }
444
+
445
+ function scheduleBackgroundRefresh<T>(
446
+ authKey: string,
447
+ repo: string,
448
+ kind: CacheKind,
449
+ number: number,
450
+ includeComments: boolean,
451
+ fetchFresh: () => Promise<FreshResult<T>>,
452
+ ): void {
453
+ queueMicrotask(() => {
454
+ const promise = fetchFresh();
455
+ promise
456
+ .then(fresh => {
457
+ storeResult(authKey, repo, kind, number, includeComments, fresh, Date.now());
458
+ })
459
+ .catch(err => {
460
+ logger.debug("github cache: background refresh failed", {
461
+ err: String(err),
462
+ repo,
463
+ kind,
464
+ number,
465
+ });
466
+ });
467
+ });
468
+ }
469
+
470
+ export async function getOrFetchView<T>(options: CacheLookupOptions<T>): Promise<CacheLookupResult<T>> {
471
+ const ttl = resolveCacheTtl(options.settings);
472
+ const now = options.now ?? Date.now();
473
+ const authKey = options.authKey === undefined ? DEFAULT_CACHE_AUTH_KEY : options.authKey;
474
+
475
+ if (!ttl.enabled || authKey === null) {
476
+ const fresh = await options.fetchFresh();
477
+ return { ...fresh, status: "disabled", fetchedAt: now };
478
+ }
479
+
480
+ // Enforce the *configured* hard TTL against on-disk rows. This is what
481
+ // makes `github.cache.hardTtlSec` a real retention cap rather than a soft
482
+ // suggestion the next `openDb()` call eventually honors.
483
+ sweepIfDue(ttl.hardMs);
484
+
485
+ const cached: CachedView<T> | null = getCached<T>(
486
+ options.repo,
487
+ options.kind,
488
+ options.number,
489
+ options.includeComments,
490
+ authKey,
491
+ );
492
+
493
+ if (cached) {
494
+ const age = now - cached.fetchedAt;
495
+ if (age > ttl.hardMs) {
496
+ // Past hard TTL: drop the row eagerly so the on-disk exposure window
497
+ // is bounded even if `fetchFresh()` then fails (network down, gh
498
+ // auth lapse, etc.) and we never get to overwrite it.
499
+ invalidate(options.repo, options.kind, options.number, options.includeComments, authKey);
500
+ } else if (age <= ttl.softMs) {
501
+ return {
502
+ rendered: cached.rendered,
503
+ sourceUrl: cached.sourceUrl,
504
+ payload: cached.payload,
505
+ status: "fresh",
506
+ fetchedAt: cached.fetchedAt,
507
+ };
508
+ } else {
509
+ scheduleBackgroundRefresh(
510
+ authKey,
511
+ options.repo,
512
+ options.kind,
513
+ options.number,
514
+ options.includeComments,
515
+ options.fetchFresh,
516
+ );
517
+ return {
518
+ rendered: cached.rendered,
519
+ sourceUrl: cached.sourceUrl,
520
+ payload: cached.payload,
521
+ status: "stale",
522
+ fetchedAt: cached.fetchedAt,
523
+ };
524
+ }
525
+ }
526
+
527
+ const fresh = await options.fetchFresh();
528
+ const fetchedAt = Date.now();
529
+ storeResult(authKey, options.repo, options.kind, options.number, options.includeComments, fresh, fetchedAt);
530
+ return { ...fresh, status: "miss", fetchedAt };
531
+ }
532
+
533
+ /**
534
+ * Human-friendly freshness note for protocol-handler `notes[]` rendering.
535
+ */
536
+ export function formatFreshnessNote(status: CacheStatus, fetchedAtMs: number, now: number = Date.now()): string {
537
+ if (status === "miss") return "Fetched live";
538
+ if (status === "disabled") return "Cache disabled; fetched live";
539
+ const ageSec = Math.max(0, Math.round((now - fetchedAtMs) / 1000));
540
+ const human =
541
+ ageSec < 60
542
+ ? `${ageSec}s ago`
543
+ : ageSec < 3600
544
+ ? `${Math.round(ageSec / 60)}m ago`
545
+ : `${Math.round(ageSec / 3600)}h ago`;
546
+ if (status === "stale") return `Cached: ${human} (refreshing in background)`;
547
+ return `Cached: ${human}`;
548
+ }
@@ -11,6 +11,7 @@ import { LspTool } from "../lsp";
11
11
  import type { PlanModeState } from "../plan-mode/state";
12
12
  import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
13
13
  import type { ArtifactManager } from "../session/artifacts";
14
+ import type { ClientBridge } from "../session/client-bridge";
14
15
  import type { CustomMessage } from "../session/messages";
15
16
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
16
17
  import { TaskTool } from "../task";
@@ -178,6 +179,8 @@ export interface ToolSession {
178
179
  settings: Settings;
179
180
  /** Plan mode state (if active) */
180
181
  getPlanModeState?: () => PlanModeState | undefined;
182
+ /** Bridge to the connected client (e.g. ACP editor host). Tools should route fs/terminal/permission requests through this when available. */
183
+ getClientBridge?: () => ClientBridge | undefined;
181
184
  /** Get compact conversation context for subagents (excludes tool results, system prompts) */
182
185
  getCompactContext?: () => string;
183
186
  /** Get cached todo phases for this session. */