@oh-my-pi/pi-coding-agent 17.1.8 → 17.2.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 (246) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/{CHANGELOG-vt8ene9g.md → CHANGELOG-dj46zzrm.md} +110 -0
  3. package/dist/cli.js +10009 -9654
  4. package/dist/{template-dys3vk5b.js → template-8vdv6xb4.js} +1 -1
  5. package/dist/types/advisor/runtime.d.ts +8 -2
  6. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  7. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  8. package/dist/types/cli/args.d.ts +2 -0
  9. package/dist/types/cli/session-picker.d.ts +14 -7
  10. package/dist/types/collab/guest.d.ts +21 -2
  11. package/dist/types/commands/launch.d.ts +6 -0
  12. package/dist/types/config/model-discovery.d.ts +12 -0
  13. package/dist/types/config/settings-schema.d.ts +73 -8
  14. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  15. package/dist/types/cursor.d.ts +193 -8
  16. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  17. package/dist/types/edit/hashline/diff.d.ts +9 -1
  18. package/dist/types/edit/index.d.ts +8 -1
  19. package/dist/types/edit/streaming.d.ts +8 -1
  20. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  21. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  22. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +25 -0
  23. package/dist/types/internal-urls/index.d.ts +1 -0
  24. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  25. package/dist/types/internal-urls/security-protocol.d.ts +15 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  28. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  29. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  30. package/dist/types/lsp/client.d.ts +2 -0
  31. package/dist/types/main.d.ts +2 -0
  32. package/dist/types/mcp/manager.d.ts +33 -4
  33. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  34. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  35. package/dist/types/modes/components/session-selector.d.ts +6 -0
  36. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  37. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  38. package/dist/types/modes/components/usage-row.d.ts +2 -0
  39. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  40. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  41. package/dist/types/modes/interactive-mode.d.ts +4 -2
  42. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  44. package/dist/types/modes/theme/theme.d.ts +12 -1
  45. package/dist/types/modes/types.d.ts +2 -1
  46. package/dist/types/sdk.d.ts +18 -0
  47. package/dist/types/security/auth.d.ts +30 -0
  48. package/dist/types/security/cloud.d.ts +79 -0
  49. package/dist/types/security/comparison.d.ts +49 -0
  50. package/dist/types/security/contracts/ids.d.ts +15 -0
  51. package/dist/types/security/contracts/index.d.ts +4 -0
  52. package/dist/types/security/contracts/schemas.d.ts +660 -0
  53. package/dist/types/security/contracts/types.d.ts +234 -0
  54. package/dist/types/security/contracts/validation.d.ts +5 -0
  55. package/dist/types/security/coordinator.d.ts +100 -0
  56. package/dist/types/security/importers/codex-security.d.ts +7 -0
  57. package/dist/types/security/importers/index.d.ts +2 -0
  58. package/dist/types/security/importers/sarif.d.ts +9 -0
  59. package/dist/types/security/index.d.ts +13 -0
  60. package/dist/types/security/preflight.d.ts +61 -0
  61. package/dist/types/security/provenance.d.ts +24 -0
  62. package/dist/types/security/publication.d.ts +78 -0
  63. package/dist/types/security/remediation.d.ts +27 -0
  64. package/dist/types/security/resource-output.d.ts +8 -0
  65. package/dist/types/security/sarif.d.ts +2 -0
  66. package/dist/types/security/store.d.ts +40 -0
  67. package/dist/types/session/agent-session-events.d.ts +2 -0
  68. package/dist/types/session/agent-session-types.d.ts +36 -1
  69. package/dist/types/session/agent-session.d.ts +4 -2
  70. package/dist/types/session/claude-session-store.d.ts +13 -0
  71. package/dist/types/session/codex-session-store.d.ts +14 -0
  72. package/dist/types/session/credential-pin.d.ts +58 -0
  73. package/dist/types/session/foreign-session-import.d.ts +15 -0
  74. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  75. package/dist/types/session/foreign-session-store.d.ts +23 -0
  76. package/dist/types/session/session-advisors.d.ts +47 -2
  77. package/dist/types/session/session-entries.d.ts +19 -1
  78. package/dist/types/session/session-handoff.d.ts +4 -1
  79. package/dist/types/session/session-loader.d.ts +2 -0
  80. package/dist/types/session/session-maintenance.d.ts +1 -1
  81. package/dist/types/session/session-manager.d.ts +21 -0
  82. package/dist/types/session/session-tools.d.ts +25 -1
  83. package/dist/types/session/turn-recovery.d.ts +10 -4
  84. package/dist/types/slash-commands/helpers/security.d.ts +2 -0
  85. package/dist/types/system-prompt.d.ts +4 -1
  86. package/dist/types/task/executor.d.ts +14 -2
  87. package/dist/types/task/render.d.ts +7 -0
  88. package/dist/types/thinking.d.ts +18 -6
  89. package/dist/types/tools/acp-bridge.d.ts +28 -5
  90. package/dist/types/tools/builtin-names.d.ts +1 -1
  91. package/dist/types/tools/grep.d.ts +17 -1
  92. package/dist/types/tools/hub/launch.d.ts +5 -1
  93. package/dist/types/tools/index.d.ts +10 -2
  94. package/dist/types/tools/path-utils.d.ts +19 -0
  95. package/dist/types/tools/read.d.ts +2 -0
  96. package/dist/types/tools/security-scan.d.ts +96 -0
  97. package/dist/types/tools/xdev.d.ts +2 -1
  98. package/dist/types/utils/changelog.d.ts +19 -0
  99. package/dist/types/utils/git.d.ts +10 -0
  100. package/dist/types/utils/jj.d.ts +11 -4
  101. package/dist/types/web/search/types.d.ts +2 -2
  102. package/package.json +12 -12
  103. package/scripts/security-compare.ts +40 -0
  104. package/src/advisor/runtime.ts +138 -12
  105. package/src/advisor/transcript-recorder.ts +56 -0
  106. package/src/auto-thinking/classifier.ts +48 -10
  107. package/src/cli/args.ts +6 -0
  108. package/src/cli/flag-tables.ts +2 -0
  109. package/src/cli/gallery-fixtures/fs.ts +25 -0
  110. package/src/cli/session-picker.ts +34 -17
  111. package/src/cli/ttsr-cli.ts +19 -1
  112. package/src/cli.ts +9 -0
  113. package/src/collab/guest.ts +37 -4
  114. package/src/collab/host.ts +1 -0
  115. package/src/commands/launch.ts +6 -0
  116. package/src/config/model-discovery.ts +32 -5
  117. package/src/config/model-registry.ts +107 -25
  118. package/src/config/settings-schema.ts +77 -6
  119. package/src/config/settings.ts +25 -0
  120. package/src/cursor-bridge-tools.ts +81 -0
  121. package/src/cursor.ts +463 -12
  122. package/src/edit/edit-clipboard.ts +23 -0
  123. package/src/edit/hashline/diff.ts +49 -10
  124. package/src/edit/hashline/execute.ts +38 -6
  125. package/src/edit/hashline/filesystem.ts +27 -3
  126. package/src/edit/index.ts +12 -2
  127. package/src/edit/renderer.ts +4 -4
  128. package/src/edit/streaming.ts +17 -2
  129. package/src/eval/py/prelude.py +7 -3
  130. package/src/export/html/template.js +1 -1
  131. package/src/extensibility/extensions/runner.ts +70 -0
  132. package/src/extensibility/extensions/types.ts +30 -0
  133. package/src/extensibility/legacy-pi-coding-agent-shim.ts +65 -13
  134. package/src/internal-urls/index.ts +1 -0
  135. package/src/internal-urls/local-protocol.ts +3 -2
  136. package/src/internal-urls/mcp-protocol.ts +4 -1
  137. package/src/internal-urls/router.ts +4 -1
  138. package/src/internal-urls/security-protocol.ts +261 -0
  139. package/src/internal-urls/types.ts +1 -1
  140. package/src/launch/terminal-output-worker-client.ts +53 -0
  141. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  142. package/src/launch/terminal-output-worker.ts +23 -0
  143. package/src/lsp/client.ts +18 -3
  144. package/src/lsp/index.ts +7 -0
  145. package/src/lsp/render.ts +1 -1
  146. package/src/main.ts +127 -56
  147. package/src/mcp/manager.ts +120 -18
  148. package/src/modes/acp/acp-agent.ts +15 -9
  149. package/src/modes/components/chat-transcript-builder.ts +30 -11
  150. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  151. package/src/modes/components/read-tool-group.ts +159 -17
  152. package/src/modes/components/session-selector.ts +19 -3
  153. package/src/modes/components/status-line/component.ts +381 -54
  154. package/src/modes/components/tool-execution.ts +78 -31
  155. package/src/modes/components/usage-row.ts +10 -5
  156. package/src/modes/controllers/event-controller.ts +26 -4
  157. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  158. package/src/modes/controllers/selector-controller.ts +94 -36
  159. package/src/modes/controllers/tan-command-controller.ts +14 -0
  160. package/src/modes/interactive-mode.ts +31 -17
  161. package/src/modes/rpc/host-uris.ts +6 -0
  162. package/src/modes/rpc/rpc-client.ts +18 -0
  163. package/src/modes/rpc/rpc-mode.ts +16 -1
  164. package/src/modes/rpc/rpc-types.ts +11 -0
  165. package/src/modes/theme/theme.ts +151 -6
  166. package/src/modes/types.ts +2 -1
  167. package/src/modes/utils/ui-helpers.ts +38 -21
  168. package/src/prompts/agents/security-reviewer.md +75 -0
  169. package/src/prompts/security/scan-coordinator.md +7 -0
  170. package/src/prompts/security/scan-request.md +21 -0
  171. package/src/prompts/security/validate-request.md +8 -0
  172. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  173. package/src/prompts/system/plan-mode-active.md +2 -2
  174. package/src/prompts/system/system-prompt.md +6 -0
  175. package/src/prompts/system/xdev-mount-notice.md +1 -1
  176. package/src/prompts/tools/bash.md +15 -17
  177. package/src/prompts/tools/checkpoint.md +1 -1
  178. package/src/prompts/tools/glob.md +7 -6
  179. package/src/prompts/tools/grep.md +7 -6
  180. package/src/prompts/tools/security-publish.md +1 -0
  181. package/src/prompts/tools/security-scan.md +1 -0
  182. package/src/sdk.ts +212 -21
  183. package/src/security/auth.ts +98 -0
  184. package/src/security/cloud.ts +686 -0
  185. package/src/security/comparison.ts +255 -0
  186. package/src/security/contracts/ids.ts +111 -0
  187. package/src/security/contracts/index.ts +4 -0
  188. package/src/security/contracts/schemas.ts +201 -0
  189. package/src/security/contracts/types.ts +254 -0
  190. package/src/security/contracts/validation.ts +65 -0
  191. package/src/security/coordinator.ts +708 -0
  192. package/src/security/importers/codex-security.ts +387 -0
  193. package/src/security/importers/index.ts +2 -0
  194. package/src/security/importers/sarif.ts +357 -0
  195. package/src/security/index.ts +13 -0
  196. package/src/security/preflight.ts +405 -0
  197. package/src/security/provenance.ts +106 -0
  198. package/src/security/publication.ts +326 -0
  199. package/src/security/remediation.ts +93 -0
  200. package/src/security/resource-output.ts +50 -0
  201. package/src/security/sarif.ts +78 -0
  202. package/src/security/store.ts +430 -0
  203. package/src/session/agent-session-events.ts +1 -0
  204. package/src/session/agent-session-types.ts +40 -1
  205. package/src/session/agent-session.ts +302 -175
  206. package/src/session/claude-session-store.ts +426 -0
  207. package/src/session/codex-session-store.ts +673 -0
  208. package/src/session/credential-pin.ts +93 -0
  209. package/src/session/foreign-session-import.ts +52 -0
  210. package/src/session/foreign-session-jsonl.ts +29 -0
  211. package/src/session/foreign-session-store.ts +26 -0
  212. package/src/session/model-controls.ts +11 -3
  213. package/src/session/session-advisors.ts +140 -16
  214. package/src/session/session-entries.ts +21 -1
  215. package/src/session/session-handoff.ts +20 -3
  216. package/src/session/session-loader.ts +25 -9
  217. package/src/session/session-maintenance.ts +59 -11
  218. package/src/session/session-manager.ts +61 -0
  219. package/src/session/session-tools.ts +107 -5
  220. package/src/session/settings-stream-fn.ts +1 -0
  221. package/src/session/turn-recovery.ts +97 -51
  222. package/src/slash-commands/builtin-registry.ts +152 -3
  223. package/src/slash-commands/helpers/security.ts +451 -0
  224. package/src/system-prompt.ts +7 -2
  225. package/src/task/agents.ts +2 -0
  226. package/src/task/executor.ts +20 -4
  227. package/src/task/render.ts +20 -4
  228. package/src/task/structured-subagent.ts +6 -4
  229. package/src/thinking.ts +29 -10
  230. package/src/tools/acp-bridge.ts +52 -8
  231. package/src/tools/browser.ts +5 -0
  232. package/src/tools/builtin-names.ts +1 -0
  233. package/src/tools/checkpoint.ts +0 -13
  234. package/src/tools/grep.ts +64 -8
  235. package/src/tools/hub/launch.ts +10 -15
  236. package/src/tools/index.ts +37 -5
  237. package/src/tools/path-utils.ts +91 -0
  238. package/src/tools/read.ts +3 -0
  239. package/src/tools/security-scan.ts +287 -0
  240. package/src/tools/write.ts +9 -3
  241. package/src/tools/xdev.ts +39 -14
  242. package/src/utils/changelog.ts +144 -4
  243. package/src/utils/git.ts +61 -14
  244. package/src/utils/jj.ts +19 -13
  245. package/src/web/search/providers/codex.ts +34 -0
  246. package/src/web/search/types.ts +1 -1
package/src/tools/read.ts CHANGED
@@ -735,6 +735,8 @@ export interface ReadToolDetails {
735
735
  method?: string;
736
736
  notes?: string[];
737
737
  meta?: OutputMeta;
738
+ /** Full on-disk byte size recorded before applying a file range. */
739
+ fileSize?: number;
738
740
  /** Raw text + start line for user-visible TUI rendering, set when content is text-like.
739
741
  * Mirrors the same lines the model receives but without hashline/line-number prefixes,
740
742
  * so the TUI can render the file content with its own gutter without re-parsing the formatted text. */
@@ -2959,6 +2961,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2959
2961
  }
2960
2962
  }
2961
2963
 
2964
+ details.fileSize = fileSize;
2962
2965
  this.#markMarkdownContentType(details, absolutePath);
2963
2966
  if (suffixResolution) {
2964
2967
  details.suffixResolution = suffixResolution;
@@ -0,0 +1,287 @@
1
+ import type { AgentTool, AgentToolResult, ToolTier } from "@oh-my-pi/pi-agent-core";
2
+ import { type } from "arktype";
3
+ import securityScanDescription from "../prompts/tools/security-scan.md" with { type: "text" };
4
+ import { selectSecurityAccount } from "../security/auth";
5
+ import {
6
+ CodexSecurityCloudClient,
7
+ type CodexSecurityCloudConfiguration,
8
+ type CodexSecurityCloudStats,
9
+ pullCodexSecurityCloudResults,
10
+ } from "../security/cloud";
11
+ import { createSecurityEvidenceId, type SecurityEvidence, type SecurityValidationStatus } from "../security/contracts";
12
+ import type { SecurityOperationSnapshot } from "../security/coordinator";
13
+ import { getSecurityCoordinator } from "../security/coordinator";
14
+ import type { SecurityTargetRequest } from "../security/preflight";
15
+ import { SecurityStore } from "../security/store";
16
+ import type { ToolSession } from "./index";
17
+ import { ToolError } from "./tool-errors";
18
+
19
+ const securityScanSchema = type({
20
+ action:
21
+ "'preflight' | 'start' | 'status' | 'cancel' | 'validate' | 'cloud_scans' | 'cloud_start' | 'cloud_status' | 'cloud_pull'",
22
+ "plan_id?": "string",
23
+ "operation_id?": "string",
24
+ "target_kind?": "'repository' | 'scoped_path' | 'ref_diff' | 'working_tree'",
25
+ "include_paths?": "string[]",
26
+ "exclude_paths?": "string[]",
27
+ "base_revision?": "string",
28
+ "head_revision?": "string",
29
+ "knowledge_base_paths?": "string[]",
30
+ "output_root?": "string",
31
+ "archive_existing?": "boolean",
32
+ "credential_id?": "number.integer >= 1",
33
+ "scan_id?": "string",
34
+ "finding_id?": "string",
35
+ "validation_status?": "'unvalidated' | 'validated' | 'rejected' | 'partial' | 'error'",
36
+ "validation_summary?": "string",
37
+ "validation_evidence?": type({ label: "string > 0", explanation: "string" }).array(),
38
+ "cloud_configuration_id?": "string",
39
+ "repository_id?": "string",
40
+ "repository_url?": "string",
41
+ "environment_id?": "string",
42
+ "lookback_days?": "number.integer >= 1 | 'all'",
43
+ });
44
+
45
+ type SecurityScanParams = typeof securityScanSchema.infer;
46
+
47
+ export interface SecurityScanToolDetails {
48
+ action: SecurityScanParams["action"];
49
+ plan?: { id: string; fingerprint: string };
50
+ operation?: SecurityOperationSnapshot;
51
+ cancelled?: boolean;
52
+ finding?: { id: string; validationStatus: SecurityValidationStatus };
53
+ cloudConfigurations?: CodexSecurityCloudConfiguration[];
54
+ cloudStats?: CodexSecurityCloudStats;
55
+ cloudScan?: { id: string; repositoryUrl: string };
56
+ importedScan?: { id: string; findingCount: number };
57
+ }
58
+
59
+ function targetFromParams(params: SecurityScanParams): SecurityTargetRequest {
60
+ const common = { includePaths: params.include_paths, excludePaths: params.exclude_paths };
61
+ switch (params.target_kind ?? "repository") {
62
+ case "scoped_path": {
63
+ if (!params.include_paths?.some(value => value.trim().length > 0)) {
64
+ throw new ToolError("scoped_path security scans require at least one include path");
65
+ }
66
+ return { kind: "scoped_path", includePaths: params.include_paths, excludePaths: params.exclude_paths };
67
+ }
68
+ case "working_tree":
69
+ return { kind: "working_tree", ...common };
70
+ case "ref_diff":
71
+ if (!params.base_revision || !params.head_revision) {
72
+ throw new ToolError("ref_diff preflight requires base_revision and head_revision");
73
+ }
74
+ return {
75
+ kind: "ref_diff",
76
+ baseRevision: params.base_revision,
77
+ headRevision: params.head_revision,
78
+ ...common,
79
+ };
80
+ default:
81
+ return { kind: "repository", ...common };
82
+ }
83
+ }
84
+
85
+ function requireValue(value: string | undefined, label: string): string {
86
+ if (!value?.trim()) throw new ToolError(`${label} is required for this action`);
87
+ return value.trim();
88
+ }
89
+
90
+ function cloudClientForSession(session: ToolSession, credentialId?: number): CodexSecurityCloudClient {
91
+ if (!session.authStorage) throw new ToolError("Codex Security cloud requires the authentication registry");
92
+ const account = selectSecurityAccount(
93
+ session.authStorage,
94
+ "openai-codex",
95
+ credentialId,
96
+ session.getSessionId?.() ?? undefined,
97
+ );
98
+ return new CodexSecurityCloudClient({ authStorage: session.authStorage, account });
99
+ }
100
+
101
+ function textResult(text: string, details: SecurityScanToolDetails): AgentToolResult<SecurityScanToolDetails> {
102
+ return { content: [{ type: "text", text }], details };
103
+ }
104
+
105
+ export class SecurityScanTool implements AgentTool<typeof securityScanSchema, SecurityScanToolDetails> {
106
+ readonly name = "security_scan";
107
+ readonly approval: ToolTier = "exec";
108
+ readonly label = "Security Scan";
109
+ readonly loadMode = "discoverable";
110
+ readonly summary = "Run OMP-native scans and explicit Codex Security cloud operations";
111
+ readonly description = securityScanDescription.trim();
112
+ readonly parameters = securityScanSchema;
113
+ readonly strict = true;
114
+
115
+ constructor(readonly session: ToolSession) {}
116
+
117
+ async execute(
118
+ _toolCallId: string,
119
+ params: SecurityScanParams,
120
+ signal?: AbortSignal,
121
+ ): Promise<AgentToolResult<SecurityScanToolDetails>> {
122
+ if (!this.session.settings.get("security.enabled")) {
123
+ throw new ToolError("Security is disabled. Enable security.enabled before using security_scan.");
124
+ }
125
+ const coordinatorForSession = () => {
126
+ if (!this.session.modelRegistry || !this.session.authStorage) {
127
+ throw new ToolError("Security scan requires the session model and authentication registries");
128
+ }
129
+ return getSecurityCoordinator({
130
+ cwd: this.session.cwd,
131
+ settings: this.session.settings,
132
+ authStorage: this.session.authStorage,
133
+ modelRegistry: this.session.modelRegistry,
134
+ activeModel: this.session.getActiveModel?.(),
135
+ sessionId: this.session.getSessionId?.() ?? undefined,
136
+ agentId: this.session.getAgentId?.() ?? undefined,
137
+ asyncJobManager: this.session.asyncJobManager,
138
+ });
139
+ };
140
+ switch (params.action) {
141
+ case "preflight": {
142
+ const model = this.session.getActiveModel?.();
143
+ const plan = await coordinatorForSession().preflight({
144
+ target: targetFromParams(params),
145
+ knowledgeBasePaths: params.knowledge_base_paths,
146
+ outputRoot: params.output_root,
147
+ archiveExisting: params.archive_existing,
148
+ credentialId: params.credential_id,
149
+ model,
150
+ signal,
151
+ });
152
+ return textResult(
153
+ [
154
+ `Security plan ${plan.id} is ready.`,
155
+ `Fingerprint: ${plan.fingerprint}.`,
156
+ `Start it with action=start and plan_id=${plan.id}.`,
157
+ ].join(" "),
158
+ { action: params.action, plan: { id: plan.id, fingerprint: plan.fingerprint } },
159
+ );
160
+ }
161
+ case "start": {
162
+ const operation = await coordinatorForSession().start({
163
+ planId: requireValue(params.plan_id, "plan_id"),
164
+ });
165
+ return textResult(`Security scan ${operation.scanId} started as ${operation.operationId}.`, {
166
+ action: params.action,
167
+ operation,
168
+ });
169
+ }
170
+ case "status": {
171
+ const operationId = requireValue(params.operation_id, "operation_id");
172
+ const operation = await coordinatorForSession().status(operationId);
173
+ if (!operation) throw new ToolError(`Unknown security operation: ${operationId}`);
174
+ return textResult(
175
+ `Security scan ${operation.scanId}: ${operation.phase}; ${operation.findingCount} finding(s).`,
176
+ { action: params.action, operation },
177
+ );
178
+ }
179
+ case "cancel": {
180
+ const operationId = requireValue(params.operation_id, "operation_id");
181
+ const cancelled = await coordinatorForSession().cancel(operationId);
182
+ return textResult(
183
+ cancelled ? `Cancellation requested for ${operationId}.` : `No running operation ${operationId}.`,
184
+ {
185
+ action: params.action,
186
+ cancelled,
187
+ operation: (await coordinatorForSession().status(operationId)) ?? undefined,
188
+ },
189
+ );
190
+ }
191
+ case "cloud_scans": {
192
+ const configurations = await cloudClientForSession(
193
+ this.session,
194
+ params.credential_id,
195
+ ).listAllConfigurations(signal);
196
+ return textResult(
197
+ configurations.length === 0
198
+ ? "No Codex Security cloud scan configurations are available."
199
+ : configurations
200
+ .map(
201
+ item =>
202
+ `${item.id} ${item.currentStep ?? "unknown"} repo=${item.repositoryId} environment=${item.environmentId} ${item.repositoryUrl}`,
203
+ )
204
+ .join("\n"),
205
+ { action: params.action, cloudConfigurations: configurations },
206
+ );
207
+ }
208
+ case "cloud_start": {
209
+ const configuration = await cloudClientForSession(this.session, params.credential_id).startScan({
210
+ repositoryId: requireValue(params.repository_id, "repository_id"),
211
+ repositoryUrl: requireValue(params.repository_url, "repository_url"),
212
+ environmentId: requireValue(params.environment_id, "environment_id"),
213
+ lookbackDays: params.lookback_days,
214
+ signal,
215
+ });
216
+ return textResult(
217
+ `Codex Security cloud scan ${configuration.id} started for ${configuration.repositoryUrl}. This consumes cloud scan allowance.`,
218
+ {
219
+ action: params.action,
220
+ cloudScan: { id: configuration.id, repositoryUrl: configuration.repositoryUrl },
221
+ },
222
+ );
223
+ }
224
+ case "cloud_status": {
225
+ const stats = await cloudClientForSession(this.session, params.credential_id).getStats(
226
+ requireValue(params.cloud_configuration_id, "cloud_configuration_id"),
227
+ signal,
228
+ );
229
+ return textResult(
230
+ `Codex Security cloud scan ${stats.configurationId}: ${stats.currentStep ?? "unknown"}; ${stats.finishedCommits} finished commit(s), ${stats.pendingCommits} pending.`,
231
+ { action: params.action, cloudStats: stats },
232
+ );
233
+ }
234
+ case "cloud_pull": {
235
+ const store = await SecurityStore.openForCwd(this.session.cwd, { signal });
236
+ const bundle = await pullCodexSecurityCloudResults({
237
+ client: cloudClientForSession(this.session, params.credential_id),
238
+ configurationId: requireValue(params.cloud_configuration_id, "cloud_configuration_id"),
239
+ store,
240
+ signal,
241
+ });
242
+ return textResult(
243
+ `Imported ${bundle.findings.length} Codex Security cloud finding(s) as security scan ${bundle.scan.id}.`,
244
+ {
245
+ action: params.action,
246
+ importedScan: { id: bundle.scan.id, findingCount: bundle.findings.length },
247
+ },
248
+ );
249
+ }
250
+ case "validate": {
251
+ const scanId = requireValue(params.scan_id, "scan_id");
252
+ const findingId = requireValue(params.finding_id, "finding_id");
253
+ const status = params.validation_status;
254
+ if (!status) throw new ToolError("validation_status is required for this action");
255
+ const summary = requireValue(params.validation_summary, "validation_summary");
256
+ const store = await SecurityStore.openForCwd(this.session.cwd, { signal });
257
+ const finding = await store.getFinding(scanId, findingId);
258
+ if (!finding) throw new ToolError(`Unknown security finding: ${findingId}`);
259
+ const evidence: SecurityEvidence[] = (params.validation_evidence ?? []).map((item, index) => ({
260
+ id: createSecurityEvidenceId(
261
+ finding.fingerprint,
262
+ `validation:${item.label}`,
263
+ finding.evidence.length + index,
264
+ ),
265
+ kind: "validation",
266
+ label: item.label,
267
+ explanation: item.explanation,
268
+ }));
269
+ const updated = await store.updateValidation(
270
+ scanId,
271
+ findingId,
272
+ {
273
+ status,
274
+ summary,
275
+ evidenceIds: evidence.map(item => item.id),
276
+ validatedAt: new Date().toISOString(),
277
+ },
278
+ evidence,
279
+ );
280
+ return textResult(`Finding ${updated.id} validation is now ${updated.validation.status}.`, {
281
+ action: params.action,
282
+ finding: { id: updated.id, validationStatus: updated.validation.status },
283
+ });
284
+ }
285
+ }
286
+ }
287
+ }
@@ -1267,9 +1267,15 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
1267
1267
 
1268
1268
  // Try ACP bridge first for editor-visible filesystem paths. Internal
1269
1269
  // artifacts such as local:// plans are owned by OMP, not the editor.
1270
- if (await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent, signal)) {
1271
- const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, cleanContent);
1272
- const header = maybeWriteSnapshotHeader(this.session, absolutePath, cleanContent);
1270
+ const bridgeWrite = await routeWriteThroughBridge(this.session, path, absolutePath, cleanContent, signal);
1271
+ if (bridgeWrite) {
1272
+ // `write` always replaces the whole file, so (unlike hashline's
1273
+ // hunk-scoped diff) there's no size cost to keying the header/
1274
+ // executable-bit check on the verified post-write content —
1275
+ // use it so a drifted write (e.g. client format-on-save) still
1276
+ // hands back a tag that matches what's actually on disk.
1277
+ const madeExecutable = await maybeMarkExecutableForShebang(absolutePath, bridgeWrite.text);
1278
+ const header = maybeWriteSnapshotHeader(this.session, absolutePath, bridgeWrite.text);
1273
1279
  const writeLine = `Successfully wrote ${cleanContent.length} bytes to ${displayPath}`;
1274
1280
  let resultText = header ? `${header}\n${writeLine}` : writeLine;
1275
1281
  if (stripped) {
package/src/tools/xdev.ts CHANGED
@@ -35,6 +35,7 @@ import { parseStreamingJson } from "@oh-my-pi/pi-utils";
35
35
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
36
36
  import { XD_URL_PREFIX } from "../internal-urls/xd-protocol";
37
37
  import type { Theme } from "../modes/theme/theme";
38
+ import { truncateHeadBytes } from "../session/streaming-output";
38
39
  import { renderDefaultToolExecution } from "./default-renderer";
39
40
  import type { Tool } from "./index";
40
41
  import { replaceTabs } from "./render-utils";
@@ -178,14 +179,34 @@ function toolSummary(inst: Tool): string {
178
179
  return firstLine?.trim() ?? inst.label ?? inst.name;
179
180
  }
180
181
 
181
- function promptCatalogSummary(inst: Tool, maxLength?: number): string {
182
+ /** C0/C1 controls and Unicode line/paragraph separators; summaries must remain one line. */
183
+ const SUMMARY_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
184
+ const SUMMARY_ELLIPSIS = "…";
185
+ const SUMMARY_ELLIPSIS_BYTES = Buffer.byteLength(SUMMARY_ELLIPSIS, "utf-8");
186
+
187
+ /**
188
+ * Bound a catalog summary for prompt rendering. External summaries are
189
+ * third-party metadata inlined verbatim, so control characters are stripped
190
+ * first, then the result is bounded in UTF-8 BYTES rather than characters (a
191
+ * character bound is not a byte bound for multi-byte scripts). The cut lands
192
+ * on a code point boundary, so the prompt never carries a partial code point.
193
+ */
194
+ function sanitizeCatalogSummary(summary: string, maxBytes?: number): string {
195
+ const cleaned = summary.replace(SUMMARY_CONTROL_CHARS, " ").trim();
196
+ if (maxBytes === undefined || Buffer.byteLength(cleaned, "utf-8") <= maxBytes) return cleaned;
197
+ if (maxBytes <= 0) return "";
198
+ if (maxBytes < SUMMARY_ELLIPSIS_BYTES) return truncateHeadBytes(cleaned, maxBytes).text;
199
+ const body = truncateHeadBytes(cleaned, maxBytes - SUMMARY_ELLIPSIS_BYTES).text.trimEnd();
200
+ return `${body}${SUMMARY_ELLIPSIS}`;
201
+ }
202
+
203
+ function promptCatalogSummary(inst: Tool, maxBytes?: number): string {
182
204
  const summary =
183
205
  toolSummary(inst)
184
206
  .split("\n")
185
207
  .find(line => line.trim().length > 0)
186
208
  ?.trim() ?? inst.name;
187
- if (maxLength === undefined || summary.length <= maxLength) return summary;
188
- return `${summary.slice(0, maxLength).trimEnd()}…`;
209
+ return sanitizeCatalogSummary(summary, maxBytes) || inst.name;
189
210
  }
190
211
 
191
212
  /** Compile the `tools.xdevInlineDevices` allowlist once per render, dropping
@@ -257,15 +278,19 @@ export function listXdevTools(state: XdevState): Tool[] {
257
278
  });
258
279
  }
259
280
 
260
- /** `{name, summary}` pairs for prompt templates and `/tools` display. */
261
- export function xdevEntries(state: XdevState): Array<{ name: string; summary: string }> {
262
- return listXdevTools(state).map(tool => ({
263
- name: tool.name,
264
- summary: promptCatalogSummary(
265
- tool,
266
- state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP,
267
- ),
268
- }));
281
+ /** `{name, summary, dynamic}` triples for prompt templates and `/tools` display. */
282
+ export function xdevEntries(state: XdevState): Array<{ name: string; summary: string; dynamic: boolean }> {
283
+ return listXdevTools(state).map(tool => {
284
+ // Built-ins are first-party; anything else carries third-party metadata. One
285
+ // boolean drives both the description cap and the flag callers present, so
286
+ // the two can never disagree about which summaries are untrusted.
287
+ const dynamic = !state.builtInNames.has(tool.name);
288
+ return {
289
+ name: tool.name,
290
+ summary: promptCatalogSummary(tool, dynamic ? XDEV_EXTERNAL_DESCRIPTION_CAP : undefined),
291
+ dynamic,
292
+ };
293
+ });
269
294
  }
270
295
 
271
296
  /** `read xd://` listing with one device per line. */
@@ -313,8 +338,8 @@ export function xdevDocsAll(
313
338
  [
314
339
  "## Additional devices (docs on demand)",
315
340
  ...overflow.map(tool => {
316
- const maxLength = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
317
- return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxLength)}`;
341
+ const maxBytes = state.builtInNames.has(tool.name) ? undefined : XDEV_EXTERNAL_DESCRIPTION_CAP;
342
+ return `- ${XD_URL_PREFIX}${tool.name} — ${promptCatalogSummary(tool, maxBytes)}`;
318
343
  }),
319
344
  "",
320
345
  `Read ${XD_URL_PREFIX}<tool> for full docs + JSON schema before first use.`,
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import { getLastChangelogVersionPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
3
3
  import bundledChangelogPath from "../../CHANGELOG.md" with { type: "file" };
4
+ import type { SettingValue } from "../config/settings";
4
5
 
5
6
  export interface ChangelogEntry {
6
7
  major: number;
@@ -28,6 +29,101 @@ export interface StartupChangelogSelection {
28
29
  persistCurrentVersion: boolean;
29
30
  truncated: boolean;
30
31
  selectedEntries: number;
32
+ totalUnseenEntries: number;
33
+ latestVersion: string | undefined;
34
+ changeCount: number;
35
+ categoryCounts: Record<string, number>;
36
+ }
37
+
38
+ const CHANGELOG_CATEGORY_ORDER = [
39
+ "Breaking Changes",
40
+ "Added",
41
+ "Changed",
42
+ "Deprecated",
43
+ "Removed",
44
+ "Fixed",
45
+ "Security",
46
+ ] as const;
47
+
48
+ function emptyStartupSelection(persistCurrentVersion: boolean): StartupChangelogSelection {
49
+ return {
50
+ markdown: undefined,
51
+ persistCurrentVersion,
52
+ truncated: false,
53
+ selectedEntries: 0,
54
+ totalUnseenEntries: 0,
55
+ latestVersion: undefined,
56
+ changeCount: 0,
57
+ categoryCounts: {},
58
+ };
59
+ }
60
+
61
+ function summarizeChangelogEntries(entries: readonly ChangelogEntry[]): {
62
+ changeCount: number;
63
+ categoryCounts: Record<string, number>;
64
+ } {
65
+ const categoryCounts: Record<string, number> = {};
66
+ let changeCount = 0;
67
+
68
+ for (const entry of entries) {
69
+ let category: string | undefined;
70
+ for (const line of entry.content.split("\n")) {
71
+ const heading = line.match(/^###\s+(.+?)\s*$/);
72
+ if (heading) {
73
+ category = heading[1];
74
+ continue;
75
+ }
76
+ if (!category || !/^-\s+\S/.test(line)) continue;
77
+ categoryCounts[category] = (categoryCounts[category] ?? 0) + 1;
78
+ changeCount++;
79
+ }
80
+ }
81
+
82
+ return { changeCount, categoryCounts };
83
+ }
84
+
85
+ function categoryLabel(category: string, count: number): string {
86
+ if (category === "Breaking Changes") {
87
+ return count === 1 ? "breaking change" : "breaking changes";
88
+ }
89
+ return category.toLowerCase();
90
+ }
91
+
92
+ /** Format the compact, deterministic startup update notice. */
93
+ export function formatStartupChangelogSummary(selection: StartupChangelogSelection): string {
94
+ const latestVersion = selection.latestVersion;
95
+ if (!latestVersion || selection.selectedEntries === 0) {
96
+ return "Updated omp. Use /changelog for recent changes.";
97
+ }
98
+
99
+ const releaseCount = selection.selectedEntries;
100
+ const changeCount = selection.changeCount;
101
+ const releaseWord = releaseCount === 1 ? "release" : "releases";
102
+ const changeWord = changeCount === 1 ? "change" : "changes";
103
+ const firstLine =
104
+ releaseCount === 1
105
+ ? `Updated to v${latestVersion} · ${changeCount} ${changeWord} in 1 release`
106
+ : `Updated to v${latestVersion} · ${changeCount} ${changeWord} across ${releaseCount} ${releaseWord}`;
107
+
108
+ const orderedCategories = [
109
+ ...CHANGELOG_CATEGORY_ORDER.filter(category => selection.categoryCounts[category]),
110
+ ...Object.keys(selection.categoryCounts)
111
+ .filter(category => !(CHANGELOG_CATEGORY_ORDER as readonly string[]).includes(category))
112
+ .sort(),
113
+ ];
114
+ const breakdown = orderedCategories
115
+ .map(
116
+ category =>
117
+ `${selection.categoryCounts[category]} ${categoryLabel(category, selection.categoryCounts[category])}`,
118
+ )
119
+ .join(" · ");
120
+ const omittedReleases = selection.totalUnseenEntries - selection.selectedEntries;
121
+ const detailHint =
122
+ omittedReleases > 0
123
+ ? `+${omittedReleases} earlier ${omittedReleases === 1 ? "release" : "releases"} · Use /changelog full for history.`
124
+ : "Use /changelog for details.";
125
+
126
+ return breakdown ? `${firstLine}\n${breakdown} · ${detailHint}` : `${firstLine}\n${detailHint}`;
31
127
  }
32
128
 
33
129
  /**
@@ -177,16 +273,17 @@ export function selectStartupChangelog(
177
273
  ): StartupChangelogSelection {
178
274
  const parsedLastVersion = parseChangelogVersion(lastVersion);
179
275
  if (!parsedLastVersion) {
180
- return { markdown: undefined, persistCurrentVersion: true, truncated: false, selectedEntries: 0 };
276
+ return emptyStartupSelection(true);
181
277
  }
182
278
  const markerVersion = lastVersion ?? "";
183
279
  if (markerVersion === currentVersion) {
184
- return { markdown: undefined, persistCurrentVersion: false, truncated: false, selectedEntries: 0 };
280
+ return emptyStartupSelection(false);
185
281
  }
186
282
 
187
- const newEntries = getNewEntries(entries, markerVersion).slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
283
+ const allNewEntries = getNewEntries(entries, markerVersion);
284
+ const newEntries = allNewEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
188
285
  if (newEntries.length === 0) {
189
- return { markdown: undefined, persistCurrentVersion: false, truncated: false, selectedEntries: 0 };
286
+ return emptyStartupSelection(false);
190
287
  }
191
288
 
192
289
  const rendered = renderChangelogEntries(newEntries, {
@@ -194,14 +291,57 @@ export function selectStartupChangelog(
194
291
  truncationHint: STARTUP_CHANGELOG_FULL_HINT,
195
292
  oldestFirst: false,
196
293
  });
294
+ const summary = summarizeChangelogEntries(newEntries);
295
+ const latestEntry = newEntries[0];
197
296
  return {
198
297
  markdown: rendered.markdown,
199
298
  persistCurrentVersion: true,
200
299
  truncated: rendered.truncated,
201
300
  selectedEntries: newEntries.length,
301
+ totalUnseenEntries: allNewEntries.length,
302
+ latestVersion: latestEntry ? `${latestEntry.major}.${latestEntry.minor}.${latestEntry.patch}` : undefined,
303
+ ...summary,
202
304
  };
203
305
  }
204
306
 
307
+ /**
308
+ * Resolve and persist the automatic startup changelog decision.
309
+ *
310
+ * Hidden mode advances the marker only for an upgrade, so downgrades do not
311
+ * erase knowledge of a newer version the user has already seen.
312
+ */
313
+ export async function resolveStartupChangelogForDisplay(options: {
314
+ mode: SettingValue<"startup.changelogMode">;
315
+ currentVersion: string;
316
+ changelogPath?: string;
317
+ agentDir?: string;
318
+ }): Promise<StartupChangelogSelection | undefined> {
319
+ const lastVersion = await readLastChangelogVersion(options.agentDir);
320
+ const parsedLastVersion = parseChangelogVersion(lastVersion);
321
+ if (!parsedLastVersion) {
322
+ await writeLastChangelogVersion(options.currentVersion, options.agentDir);
323
+ return undefined;
324
+ }
325
+ if (lastVersion === options.currentVersion) {
326
+ // Steady state: skip the changelog file read and parse.
327
+ return undefined;
328
+ }
329
+ if (options.mode === "hidden") {
330
+ const currentVersion = parseChangelogVersion(options.currentVersion);
331
+ if (currentVersion && compareVersions(currentVersion, parsedLastVersion) > 0) {
332
+ await writeLastChangelogVersion(options.currentVersion, options.agentDir);
333
+ }
334
+ return undefined;
335
+ }
336
+
337
+ const entries = await parseChangelog(options.changelogPath);
338
+ const startupChangelog = selectStartupChangelog(entries, lastVersion, options.currentVersion);
339
+ if (startupChangelog.persistCurrentVersion) {
340
+ await writeLastChangelogVersion(options.currentVersion, options.agentDir);
341
+ }
342
+ return startupChangelog.markdown ? startupChangelog : undefined;
343
+ }
344
+
205
345
  // Re-export getChangelogPath from paths.ts for convenience
206
346
  export { getChangelogPath } from "../config";
207
347