@oh-my-pi/pi-coding-agent 14.9.9 → 15.0.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 (230) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +9 -9
  4. package/scripts/build-binary.ts +5 -0
  5. package/scripts/format-prompts.ts +1 -1
  6. package/src/autoresearch/helpers.ts +17 -0
  7. package/src/autoresearch/tools/log-experiment.ts +9 -17
  8. package/src/autoresearch/tools/run-experiment.ts +2 -17
  9. package/src/capability/skill.ts +7 -0
  10. package/src/cli/args.ts +2 -2
  11. package/src/cli/list-models.ts +1 -1
  12. package/src/cli/shell-cli.ts +3 -13
  13. package/src/cli/update-cli.ts +1 -1
  14. package/src/cli.ts +11 -29
  15. package/src/commands/acp.ts +24 -0
  16. package/src/commands/launch.ts +6 -4
  17. package/src/commit/agentic/prompts/system.md +1 -1
  18. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  19. package/src/commit/analysis/conventional.ts +8 -66
  20. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  21. package/src/commit/pipeline.ts +2 -2
  22. package/src/commit/shared-llm.ts +89 -0
  23. package/src/config/config-file.ts +210 -0
  24. package/src/config/model-equivalence.ts +8 -11
  25. package/src/config/model-registry.ts +13 -2
  26. package/src/config/model-resolver.ts +31 -4
  27. package/src/config/settings-schema.ts +102 -1
  28. package/src/config/settings.ts +1 -1
  29. package/src/config.ts +3 -219
  30. package/src/edit/index.ts +22 -1
  31. package/src/edit/modes/patch.ts +10 -0
  32. package/src/edit/modes/replace.ts +3 -0
  33. package/src/edit/renderer.ts +17 -1
  34. package/src/eval/js/context-manager.ts +1 -1
  35. package/src/eval/js/executor.ts +3 -0
  36. package/src/eval/js/shared/rewrite-imports.ts +122 -50
  37. package/src/eval/js/shared/runtime.ts +31 -4
  38. package/src/eval/js/tool-bridge.ts +43 -21
  39. package/src/eval/py/executor.ts +5 -0
  40. package/src/exa/factory.ts +2 -2
  41. package/src/exa/mcp-client.ts +74 -1
  42. package/src/exec/bash-executor.ts +5 -1
  43. package/src/export/html/template.generated.ts +1 -1
  44. package/src/export/html/template.js +0 -11
  45. package/src/extensibility/extensions/runner.ts +55 -2
  46. package/src/extensibility/extensions/types.ts +98 -221
  47. package/src/extensibility/hooks/types.ts +89 -314
  48. package/src/extensibility/shared-events.ts +343 -0
  49. package/src/extensibility/skills.ts +42 -1
  50. package/src/goals/index.ts +3 -0
  51. package/src/goals/runtime.ts +500 -0
  52. package/src/goals/state.ts +37 -0
  53. package/src/goals/tools/goal-tool.ts +237 -0
  54. package/src/hashline/anchors.ts +2 -2
  55. package/src/hindsight/mental-models.ts +1 -1
  56. package/src/internal-urls/agent-protocol.ts +1 -20
  57. package/src/internal-urls/artifact-protocol.ts +1 -19
  58. package/src/internal-urls/docs-index.generated.ts +9 -10
  59. package/src/internal-urls/index.ts +1 -0
  60. package/src/internal-urls/issue-pr-protocol.ts +577 -0
  61. package/src/internal-urls/registry-helpers.ts +25 -0
  62. package/src/internal-urls/router.ts +6 -3
  63. package/src/internal-urls/types.ts +22 -1
  64. package/src/main.ts +24 -11
  65. package/src/mcp/oauth-flow.ts +20 -0
  66. package/src/modes/acp/acp-agent.ts +412 -71
  67. package/src/modes/acp/acp-client-bridge.ts +152 -0
  68. package/src/modes/acp/acp-event-mapper.ts +180 -15
  69. package/src/modes/acp/terminal-auth.ts +37 -0
  70. package/src/modes/components/assistant-message.ts +14 -8
  71. package/src/modes/components/bash-execution.ts +24 -63
  72. package/src/modes/components/custom-message.ts +14 -40
  73. package/src/modes/components/eval-execution.ts +27 -57
  74. package/src/modes/components/execution-shared.ts +102 -0
  75. package/src/modes/components/hook-message.ts +17 -49
  76. package/src/modes/components/mcp-add-wizard.ts +26 -5
  77. package/src/modes/components/message-frame.ts +88 -0
  78. package/src/modes/components/model-selector.ts +1 -1
  79. package/src/modes/components/read-tool-group.ts +29 -1
  80. package/src/modes/components/session-observer-overlay.ts +6 -2
  81. package/src/modes/components/session-selector.ts +1 -1
  82. package/src/modes/components/status-line/segments.ts +55 -4
  83. package/src/modes/components/status-line/types.ts +4 -0
  84. package/src/modes/components/status-line.ts +28 -10
  85. package/src/modes/components/tool-execution.ts +7 -8
  86. package/src/modes/controllers/command-controller-shared.ts +108 -0
  87. package/src/modes/controllers/command-controller.ts +27 -10
  88. package/src/modes/controllers/event-controller.ts +60 -18
  89. package/src/modes/controllers/extension-ui-controller.ts +8 -2
  90. package/src/modes/controllers/input-controller.ts +85 -39
  91. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  92. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  93. package/src/modes/interactive-mode.ts +675 -39
  94. package/src/modes/print-mode.ts +16 -86
  95. package/src/modes/rpc/rpc-mode.ts +30 -88
  96. package/src/modes/runtime-init.ts +115 -0
  97. package/src/modes/theme/defaults/dark-poimandres.json +2 -0
  98. package/src/modes/theme/defaults/light-poimandres.json +2 -0
  99. package/src/modes/theme/theme.ts +18 -6
  100. package/src/modes/types.ts +20 -5
  101. package/src/modes/utils/context-usage.ts +13 -13
  102. package/src/modes/utils/ui-helpers.ts +25 -6
  103. package/src/plan-mode/approved-plan.ts +35 -1
  104. package/src/prompts/agents/designer.md +5 -5
  105. package/src/prompts/agents/explore.md +7 -7
  106. package/src/prompts/agents/init.md +9 -9
  107. package/src/prompts/agents/librarian.md +14 -14
  108. package/src/prompts/agents/plan.md +4 -4
  109. package/src/prompts/agents/reviewer.md +5 -5
  110. package/src/prompts/agents/task.md +10 -10
  111. package/src/prompts/commands/orchestrate.md +2 -2
  112. package/src/prompts/compaction/branch-summary.md +3 -3
  113. package/src/prompts/compaction/compaction-short-summary.md +7 -7
  114. package/src/prompts/compaction/compaction-summary-context.md +1 -1
  115. package/src/prompts/compaction/compaction-summary.md +5 -5
  116. package/src/prompts/compaction/compaction-turn-prefix.md +3 -3
  117. package/src/prompts/compaction/compaction-update-summary.md +11 -11
  118. package/src/prompts/goals/goal-budget-limit.md +16 -0
  119. package/src/prompts/goals/goal-continuation.md +28 -0
  120. package/src/prompts/goals/goal-mode-active.md +23 -0
  121. package/src/prompts/memories/consolidation.md +2 -2
  122. package/src/prompts/memories/read-path.md +1 -1
  123. package/src/prompts/memories/stage_one_input.md +1 -1
  124. package/src/prompts/memories/stage_one_system.md +5 -5
  125. package/src/prompts/review-request.md +4 -4
  126. package/src/prompts/system/agent-creation-architect.md +17 -17
  127. package/src/prompts/system/agent-creation-user.md +2 -2
  128. package/src/prompts/system/commit-message-system.md +2 -2
  129. package/src/prompts/system/custom-system-prompt.md +2 -2
  130. package/src/prompts/system/eager-todo.md +6 -6
  131. package/src/prompts/system/handoff-document.md +1 -1
  132. package/src/prompts/system/plan-mode-active.md +25 -24
  133. package/src/prompts/system/plan-mode-approved.md +4 -4
  134. package/src/prompts/system/plan-mode-compact-instructions.md +16 -0
  135. package/src/prompts/system/plan-mode-reference.md +2 -2
  136. package/src/prompts/system/plan-mode-subagent.md +8 -8
  137. package/src/prompts/system/plan-mode-tool-decision-reminder.md +3 -3
  138. package/src/prompts/system/project-prompt.md +4 -4
  139. package/src/prompts/system/subagent-system-prompt.md +7 -7
  140. package/src/prompts/system/subagent-yield-reminder.md +4 -4
  141. package/src/prompts/system/system-prompt.md +72 -71
  142. package/src/prompts/system/ttsr-interrupt.md +1 -1
  143. package/src/prompts/tools/apply-patch.md +1 -1
  144. package/src/prompts/tools/ast-edit.md +3 -3
  145. package/src/prompts/tools/ast-grep.md +3 -3
  146. package/src/prompts/tools/bash.md +6 -0
  147. package/src/prompts/tools/browser.md +3 -3
  148. package/src/prompts/tools/checkpoint.md +3 -3
  149. package/src/prompts/tools/find.md +3 -3
  150. package/src/prompts/tools/github.md +2 -5
  151. package/src/prompts/tools/goal.md +13 -0
  152. package/src/prompts/tools/hashline.md +104 -116
  153. package/src/prompts/tools/image-gen.md +3 -3
  154. package/src/prompts/tools/irc.md +1 -1
  155. package/src/prompts/tools/lsp.md +2 -2
  156. package/src/prompts/tools/patch.md +6 -6
  157. package/src/prompts/tools/read.md +8 -7
  158. package/src/prompts/tools/replace.md +5 -5
  159. package/src/prompts/tools/resolve.md +6 -5
  160. package/src/prompts/tools/retain.md +1 -1
  161. package/src/prompts/tools/rewind.md +2 -2
  162. package/src/prompts/tools/search.md +2 -2
  163. package/src/prompts/tools/ssh.md +2 -2
  164. package/src/prompts/tools/task.md +12 -6
  165. package/src/prompts/tools/web-search.md +2 -2
  166. package/src/prompts/tools/write.md +3 -3
  167. package/src/sdk.ts +81 -17
  168. package/src/session/agent-session.ts +656 -125
  169. package/src/session/blob-store.ts +36 -3
  170. package/src/session/client-bridge.ts +81 -0
  171. package/src/session/compaction/errors.ts +31 -0
  172. package/src/session/compaction/index.ts +1 -0
  173. package/src/session/messages.ts +67 -2
  174. package/src/session/session-manager.ts +131 -12
  175. package/src/session/session-storage.ts +33 -15
  176. package/src/session/streaming-output.ts +309 -13
  177. package/src/slash-commands/acp-builtins.ts +46 -0
  178. package/src/slash-commands/builtin-registry.ts +717 -116
  179. package/src/slash-commands/helpers/context-report.ts +39 -0
  180. package/src/slash-commands/helpers/format.ts +23 -0
  181. package/src/slash-commands/helpers/marketplace-manager.ts +25 -0
  182. package/src/slash-commands/helpers/mcp.ts +532 -0
  183. package/src/slash-commands/helpers/parse.ts +85 -0
  184. package/src/slash-commands/helpers/ssh.ts +193 -0
  185. package/src/slash-commands/helpers/todo.ts +279 -0
  186. package/src/slash-commands/helpers/usage-report.ts +91 -0
  187. package/src/slash-commands/types.ts +126 -0
  188. package/src/ssh/ssh-executor.ts +5 -0
  189. package/src/system-prompt.ts +4 -2
  190. package/src/task/executor.ts +27 -10
  191. package/src/task/index.ts +20 -1
  192. package/src/task/render.ts +27 -18
  193. package/src/task/types.ts +4 -0
  194. package/src/tools/ast-edit.ts +21 -120
  195. package/src/tools/ast-grep.ts +21 -119
  196. package/src/tools/bash-interactive.ts +9 -1
  197. package/src/tools/bash.ts +203 -6
  198. package/src/tools/browser/attach.ts +3 -3
  199. package/src/tools/browser/launch.ts +81 -18
  200. package/src/tools/browser/registry.ts +1 -5
  201. package/src/tools/browser/tab-supervisor.ts +51 -14
  202. package/src/tools/conflict-detect.ts +21 -10
  203. package/src/tools/eval.ts +3 -1
  204. package/src/tools/fetch.ts +15 -4
  205. package/src/tools/find.ts +39 -39
  206. package/src/tools/gh-renderer.ts +0 -12
  207. package/src/tools/gh.ts +689 -182
  208. package/src/tools/github-cache.ts +548 -0
  209. package/src/tools/index.ts +25 -11
  210. package/src/tools/inspect-image.ts +3 -10
  211. package/src/tools/output-meta.ts +176 -37
  212. package/src/tools/path-utils.ts +125 -2
  213. package/src/tools/read.ts +605 -239
  214. package/src/tools/render-utils.ts +92 -0
  215. package/src/tools/renderers.ts +2 -0
  216. package/src/tools/resolve.ts +72 -44
  217. package/src/tools/search.ts +120 -186
  218. package/src/tools/write.ts +67 -10
  219. package/src/tui/code-cell.ts +70 -2
  220. package/src/utils/file-mentions.ts +1 -1
  221. package/src/utils/image-loading.ts +7 -3
  222. package/src/utils/image-resize.ts +32 -43
  223. package/src/vim/parser.ts +0 -17
  224. package/src/vim/render.ts +1 -1
  225. package/src/vim/types.ts +1 -1
  226. package/src/web/search/providers/gemini.ts +35 -95
  227. package/src/prompts/tools/exit-plan-mode.md +0 -6
  228. package/src/tools/exit-plan-mode.ts +0 -97
  229. package/src/utils/fuzzy.ts +0 -108
  230. package/src/utils/image-convert.ts +0 -27
@@ -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
+ }
@@ -6,11 +6,14 @@ import type { Settings } from "../config/settings";
6
6
  import { EditTool } from "../edit";
7
7
  import { checkPythonKernelAvailability } from "../eval/py/kernel";
8
8
  import type { Skill } from "../extensibility/skills";
9
+ import type { GoalModeState, GoalRuntime } from "../goals";
10
+ import { GoalTool } from "../goals/tools/goal-tool";
9
11
  import type { HindsightSessionState } from "../hindsight/state";
10
12
  import { LspTool } from "../lsp";
11
13
  import type { PlanModeState } from "../plan-mode/state";
12
14
  import { type AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry";
13
15
  import type { ArtifactManager } from "../session/artifacts";
16
+ import type { ClientBridge } from "../session/client-bridge";
14
17
  import type { CustomMessage } from "../session/messages";
15
18
  import type { ToolChoiceQueue } from "../session/tool-choice-queue";
16
19
  import { TaskTool } from "../task";
@@ -28,7 +31,6 @@ import { CalculatorTool } from "./calculator";
28
31
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
29
32
  import { DebugTool } from "./debug";
30
33
  import { EvalTool } from "./eval";
31
- import { ExitPlanModeTool } from "./exit-plan-mode";
32
34
  import { FindTool } from "./find";
33
35
  import { GithubTool } from "./gh";
34
36
  import { HindsightRecallTool } from "./hindsight-recall";
@@ -56,6 +58,7 @@ import { YieldTool } from "./yield";
56
58
  export * from "../edit";
57
59
  export * from "../exa";
58
60
  export type * from "../exa/types";
61
+ export * from "../goals";
59
62
  export * from "../lsp";
60
63
  export * from "../session/streaming-output";
61
64
  export * from "../task";
@@ -69,7 +72,6 @@ export * from "./calculator";
69
72
  export * from "./checkpoint";
70
73
  export * from "./debug";
71
74
  export * from "./eval";
72
- export * from "./exit-plan-mode";
73
75
  export * from "./find";
74
76
  export * from "./gh";
75
77
  export * from "./hindsight-recall";
@@ -178,6 +180,12 @@ export interface ToolSession {
178
180
  settings: Settings;
179
181
  /** Plan mode state (if active) */
180
182
  getPlanModeState?: () => PlanModeState | undefined;
183
+ /** Goal mode state (if active or paused) */
184
+ getGoalModeState?: () => GoalModeState | undefined;
185
+ /** Goal runtime for the active agent session. */
186
+ getGoalRuntime?: () => GoalRuntime | undefined;
187
+ /** Bridge to the connected client (e.g. ACP editor host). Tools should route fs/terminal/permission requests through this when available. */
188
+ getClientBridge?: () => ClientBridge | undefined;
181
189
  /** Get compact conversation context for subagents (excludes tool results, system prompts) */
182
190
  getCompactContext?: () => string;
183
191
  /** Get cached todo phases for this session. */
@@ -217,6 +225,12 @@ export interface ToolSession {
217
225
  steer?(message: { customType: string; content: string; details?: unknown }): void;
218
226
  /** Peek the currently in-flight tool-choice queue directive's invocation handler. Used by the `resolve` tool to dispatch to the pending action. */
219
227
  peekQueueInvoker?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
228
+ /** Peek the long-lived "standing" resolve handler registered by a mode (e.g. plan mode).
229
+ * Consulted by the `resolve` tool as a fallback when no queue invoker is in flight,
230
+ * letting modes accept `resolve` invocations without forcing the tool choice every turn. */
231
+ peekStandingResolveHandler?(): ((input: unknown) => Promise<unknown> | unknown) | undefined;
232
+ /** Register or clear the standing resolve handler. Passing `null` clears it. */
233
+ setStandingResolveHandler?(handler: ((input: unknown) => Promise<unknown> | unknown) | null): void;
220
234
  /** Get active checkpoint state if any. */
221
235
  getCheckpointState?: () => CheckpointState | undefined;
222
236
  /** Set or clear active checkpoint state. */
@@ -300,8 +314,8 @@ export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
300
314
  yield: s => new YieldTool(s),
301
315
  report_finding: () => reportFindingTool,
302
316
  report_tool_issue: s => createReportToolIssueTool(s),
303
- exit_plan_mode: s => new ExitPlanModeTool(s),
304
317
  resolve: s => new ResolveTool(s),
318
+ goal: s => new GoalTool(s),
305
319
  };
306
320
 
307
321
  export type ToolName = keyof typeof BUILTIN_TOOLS;
@@ -348,11 +362,12 @@ export function resolveEvalBackends(session: ToolSession): EvalBackendsAllowance
348
362
  export async function createTools(session: ToolSession, toolNames?: string[]): Promise<Tool[]> {
349
363
  const includeYield = session.requireYieldTool === true;
350
364
  const enableLsp = session.enableLsp ?? true;
351
- const requestedTools =
365
+ let requestedTools =
352
366
  toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
353
- const planEnabled = session.settings.get("plan.enabled");
354
- if (planEnabled && requestedTools && !requestedTools.includes("exit_plan_mode")) {
355
- requestedTools.push("exit_plan_mode");
367
+ const goalEnabled = session.settings.get("goal.enabled");
368
+ const goalModeActive = goalEnabled && session.getGoalModeState?.()?.enabled === true;
369
+ if (goalModeActive && requestedTools && !requestedTools.includes("goal")) {
370
+ requestedTools = [...requestedTools, "goal"];
356
371
  }
357
372
  const backends = resolveEvalBackends(session);
358
373
  const allowPython = backends.python;
@@ -425,7 +440,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
425
440
 
426
441
  const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
427
442
  const isToolAllowed = (name: string) => {
428
- if (name === "exit_plan_mode") return planEnabled;
443
+ if (name === "goal") return goalEnabled && goalModeActive;
429
444
  if (name === "lsp") return enableLsp && session.settings.get("lsp.enabled");
430
445
  if (name === "bash") return true;
431
446
  if (name === "eval") return allowEval;
@@ -475,7 +490,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
475
490
  .filter(([name]) => isToolAllowed(name))
476
491
  .map(([name, factory]) => [name, factory] as const),
477
492
  ...(includeYield ? ([["yield", HIDDEN_TOOLS.yield]] as const) : []),
478
- ...(planEnabled ? ([["exit_plan_mode", HIDDEN_TOOLS.exit_plan_mode]] as const) : []),
493
+ ...(goalModeActive ? ([["goal", HIDDEN_TOOLS.goal]] as const) : []),
479
494
  ];
480
495
 
481
496
  const baseResults = await Promise.all(
@@ -485,8 +500,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
485
500
  }),
486
501
  );
487
502
  const tools = baseResults.filter((r): r is Tool => r !== null);
488
- const hasDeferrableTools = tools.some(tool => tool.deferrable === true);
489
- if (hasDeferrableTools && !tools.some(tool => tool.name === "resolve")) {
503
+ if (!tools.some(tool => tool.name === "resolve")) {
490
504
  const resolveTool = await logger.time("createTools:resolve", HIDDEN_TOOLS.resolve, session);
491
505
  if (resolveTool) {
492
506
  tools.push(wrapToolWithMetaNotice(resolveTool));
@@ -1,7 +1,8 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
- import { type Api, type AssistantMessage, completeSimple, type Model } from "@oh-my-pi/pi-ai";
2
+ import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
3
3
  import { prompt } from "@oh-my-pi/pi-utils";
4
4
  import { type Static, Type } from "@sinclair/typebox";
5
+ import { extractTextContent } from "../commit/utils";
5
6
  import { expandRoleAlias, resolveModelFromString } from "../config/model-resolver";
6
7
  import inspectImageDescription from "../prompts/tools/inspect-image.md" with { type: "text" };
7
8
  import inspectImageSystemPromptTemplate from "../prompts/tools/inspect-image-system.md" with { type: "text" };
@@ -30,14 +31,6 @@ export interface InspectImageToolDetails {
30
31
  mimeType: string;
31
32
  }
32
33
 
33
- function extractResponseText(message: AssistantMessage): string {
34
- return message.content
35
- .filter(content => content.type === "text")
36
- .map(content => content.text)
37
- .join("")
38
- .trim();
39
- }
40
-
41
34
  export class InspectImageTool implements AgentTool<typeof inspectImageSchema, InspectImageToolDetails> {
42
35
  readonly name = "inspect_image";
43
36
  readonly label = "InspectImage";
@@ -151,7 +144,7 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
151
144
  throw new ToolError("inspect_image request aborted.");
152
145
  }
153
146
 
154
- const text = extractResponseText(response);
147
+ const text = extractTextContent(response);
155
148
  if (!text) {
156
149
  throw new ToolError("inspect_image model returned no text output.");
157
150
  }