@oh-my-pi/pi-coding-agent 12.12.3 → 12.14.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 (40) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/package.json +8 -7
  3. package/scripts/generate-docs-index.ts +56 -0
  4. package/src/cli/ssh-cli.ts +179 -0
  5. package/src/cli.ts +1 -0
  6. package/src/commands/ssh.ts +60 -0
  7. package/src/commit/prompts/analysis-system.md +1 -3
  8. package/src/config/prompt-templates.ts +20 -5
  9. package/src/config/settings-schema.ts +10 -1
  10. package/src/discovery/builtin.ts +14 -4
  11. package/src/discovery/ssh.ts +26 -19
  12. package/src/extensibility/extensions/types.ts +1 -0
  13. package/src/internal-urls/docs-index.generated.ts +101 -0
  14. package/src/internal-urls/docs-protocol.ts +84 -0
  15. package/src/internal-urls/index.ts +1 -0
  16. package/src/internal-urls/router.ts +1 -1
  17. package/src/modes/controllers/event-controller.ts +20 -0
  18. package/src/modes/controllers/ssh-command-controller.ts +452 -0
  19. package/src/modes/interactive-mode.ts +6 -0
  20. package/src/modes/types.ts +1 -0
  21. package/src/patch/diff.ts +1 -1
  22. package/src/patch/hashline.ts +274 -303
  23. package/src/patch/index.ts +324 -103
  24. package/src/patch/shared.ts +25 -28
  25. package/src/prompts/system/system-prompt.md +14 -2
  26. package/src/prompts/tools/bash.md +1 -1
  27. package/src/prompts/tools/grep.md +12 -8
  28. package/src/prompts/tools/hashline.md +207 -60
  29. package/src/prompts/tools/read.md +3 -3
  30. package/src/sdk.ts +17 -0
  31. package/src/session/agent-session.ts +1 -0
  32. package/src/session/auth-storage.ts +6 -0
  33. package/src/slash-commands/builtin-registry.ts +20 -0
  34. package/src/ssh/config-writer.ts +183 -0
  35. package/src/tools/bash-interactive.ts +47 -7
  36. package/src/tools/fetch.ts +4 -3
  37. package/src/tools/grep.ts +14 -4
  38. package/src/tools/read.ts +2 -2
  39. package/src/tools/ssh.ts +1 -1
  40. package/src/web/search/render.ts +2 -2
@@ -0,0 +1,183 @@
1
+ /**
2
+ * SSH Configuration File Writer
3
+ *
4
+ * Utilities for reading/writing ssh.json files at user or project level.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { isEnoent } from "@oh-my-pi/pi-utils";
9
+
10
+ export interface SSHHostConfig {
11
+ host: string;
12
+ username?: string;
13
+ port?: number;
14
+ keyPath?: string;
15
+ description?: string;
16
+ compat?: boolean;
17
+ }
18
+
19
+ export interface SSHConfigFile {
20
+ hosts?: Record<string, SSHHostConfig>;
21
+ }
22
+
23
+ /**
24
+ * Read an SSH config file.
25
+ * Returns empty config if file doesn't exist.
26
+ */
27
+ export async function readSSHConfigFile(filePath: string): Promise<SSHConfigFile> {
28
+ try {
29
+ const content = await fs.promises.readFile(filePath, "utf-8");
30
+ const parsed = JSON.parse(content) as SSHConfigFile;
31
+ return parsed;
32
+ } catch (error) {
33
+ if (isEnoent(error)) {
34
+ // File doesn't exist, return empty config
35
+ return { hosts: {} };
36
+ }
37
+ if (error instanceof SyntaxError) {
38
+ throw new Error(`Failed to parse SSH config file ${filePath}: ${error.message}`);
39
+ }
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Write an SSH config file atomically.
46
+ * Creates parent directories if they don't exist.
47
+ */
48
+ export async function writeSSHConfigFile(filePath: string, config: SSHConfigFile): Promise<void> {
49
+ // Ensure parent directory exists
50
+ const dir = path.dirname(filePath);
51
+ await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
52
+
53
+ // Write to temp file first (atomic write)
54
+ const tmpPath = `${filePath}.tmp`;
55
+ const content = JSON.stringify(config, null, 2);
56
+ await fs.promises.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
57
+
58
+ // Rename to final path (atomic on most systems)
59
+ await fs.promises.rename(tmpPath, filePath);
60
+ }
61
+
62
+ /**
63
+ * Validate host name.
64
+ * @returns Error message if invalid, undefined if valid
65
+ */
66
+ export function validateHostName(name: string): string | undefined {
67
+ if (!name) {
68
+ return "Host name cannot be empty";
69
+ }
70
+ if (name.length > 100) {
71
+ return "Host name is too long (max 100 characters)";
72
+ }
73
+ // Check for invalid characters (only allow alphanumeric, dash, underscore, dot)
74
+ if (!/^[a-zA-Z0-9_.-]+$/.test(name)) {
75
+ return "Host name can only contain letters, numbers, dash, underscore, and dot";
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /**
81
+ * Add an SSH host to a config file.
82
+ *
83
+ * @throws Error if host name already exists or validation fails
84
+ */
85
+ export async function addSSHHost(filePath: string, name: string, hostConfig: SSHHostConfig): Promise<void> {
86
+ // Validate host name
87
+ const nameError = validateHostName(name);
88
+ if (nameError) {
89
+ throw new Error(nameError);
90
+ }
91
+
92
+ // Validate host field
93
+ if (!hostConfig.host) {
94
+ throw new Error("Host address cannot be empty");
95
+ }
96
+
97
+ // Read existing config
98
+ const existing = await readSSHConfigFile(filePath);
99
+
100
+ // Check for duplicate name
101
+ if (existing.hosts?.[name]) {
102
+ throw new Error(`Host "${name}" already exists in ${filePath}`);
103
+ }
104
+
105
+ // Add host
106
+ const updated: SSHConfigFile = {
107
+ ...existing,
108
+ hosts: {
109
+ ...existing.hosts,
110
+ [name]: hostConfig,
111
+ },
112
+ };
113
+
114
+ // Write back
115
+ await writeSSHConfigFile(filePath, updated);
116
+ }
117
+
118
+ /**
119
+ * Update an existing SSH host in a config file.
120
+ * If the host doesn't exist, this will add it.
121
+ *
122
+ * @throws Error if validation fails
123
+ */
124
+ export async function updateSSHHost(filePath: string, name: string, hostConfig: SSHHostConfig): Promise<void> {
125
+ // Validate host name
126
+ const nameError = validateHostName(name);
127
+ if (nameError) {
128
+ throw new Error(nameError);
129
+ }
130
+
131
+ // Validate host field
132
+ if (!hostConfig.host) {
133
+ throw new Error("Host address cannot be empty");
134
+ }
135
+
136
+ // Read existing config
137
+ const existing = await readSSHConfigFile(filePath);
138
+
139
+ // Update host
140
+ const updated: SSHConfigFile = {
141
+ ...existing,
142
+ hosts: {
143
+ ...existing.hosts,
144
+ [name]: hostConfig,
145
+ },
146
+ };
147
+
148
+ // Write back
149
+ await writeSSHConfigFile(filePath, updated);
150
+ }
151
+
152
+ /**
153
+ * Remove an SSH host from a config file.
154
+ *
155
+ * @throws Error if host doesn't exist
156
+ */
157
+ export async function removeSSHHost(filePath: string, name: string): Promise<void> {
158
+ // Read existing config
159
+ const existing = await readSSHConfigFile(filePath);
160
+
161
+ // Check if host exists
162
+ if (!existing.hosts?.[name]) {
163
+ throw new Error(`Host "${name}" not found in ${filePath}`);
164
+ }
165
+
166
+ // Remove host
167
+ const { [name]: _removed, ...remaining } = existing.hosts;
168
+ const updated: SSHConfigFile = {
169
+ ...existing,
170
+ hosts: remaining,
171
+ };
172
+
173
+ // Write back
174
+ await writeSSHConfigFile(filePath, updated);
175
+ }
176
+
177
+ /**
178
+ * List all host names in a config file.
179
+ */
180
+ export async function listSSHHosts(filePath: string): Promise<string[]> {
181
+ const config = await readSSHConfigFile(filePath);
182
+ return Object.keys(config.hosts ?? {});
183
+ }
@@ -95,6 +95,10 @@ class BashInteractiveOverlayComponent implements Component {
95
95
  #session: PtySession | null = null;
96
96
  #lastCols = 0;
97
97
  #lastRows = 0;
98
+ #writeQueue: string[] = [];
99
+ #writeOffset = 0;
100
+ #flushResolvers: Array<() => void> = [];
101
+ #writing = false;
98
102
 
99
103
  constructor(
100
104
  private readonly command: string,
@@ -117,7 +121,47 @@ class BashInteractiveOverlayComponent implements Component {
117
121
  }
118
122
 
119
123
  appendOutput(chunk: string): void {
120
- this.#terminal.write(chunk);
124
+ this.#writeQueue.push(chunk);
125
+ this.#drainQueue();
126
+ }
127
+
128
+ #drainQueue(): void {
129
+ if (this.#writing) return;
130
+ if (this.#writeOffset >= this.#writeQueue.length) {
131
+ this.#resolveFlushWaiters();
132
+ return;
133
+ }
134
+ this.#writing = true;
135
+ const data = this.#writeQueue[this.#writeOffset]!;
136
+ this.#terminal.write(data, () => {
137
+ this.#writing = false;
138
+ this.#writeOffset += 1;
139
+ if (this.#writeOffset >= this.#writeQueue.length) {
140
+ this.#writeQueue = [];
141
+ this.#writeOffset = 0;
142
+ this.#resolveFlushWaiters();
143
+ }
144
+ this.#drainQueue();
145
+ });
146
+ }
147
+
148
+ #resolveFlushWaiters(): void {
149
+ if (this.#writing || this.#writeOffset < this.#writeQueue.length) return;
150
+ if (this.#flushResolvers.length === 0) return;
151
+ const resolvers = this.#flushResolvers;
152
+ this.#flushResolvers = [];
153
+ for (const resolve of resolvers) {
154
+ resolve();
155
+ }
156
+ }
157
+
158
+ flushOutput(): Promise<void> {
159
+ if (!this.#writing && this.#writeOffset >= this.#writeQueue.length) {
160
+ return Promise.resolve();
161
+ }
162
+ const { promise, resolve } = Promise.withResolvers<void>();
163
+ this.#flushResolvers.push(resolve);
164
+ return promise;
121
165
  }
122
166
 
123
167
  setSession(session: PtySession): void {
@@ -258,6 +302,7 @@ export async function runInteractiveBashPty(
258
302
  component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
259
303
  tui.requestRender();
260
304
  void (async () => {
305
+ await component.flushOutput();
261
306
  await pendingChunks;
262
307
  const summary = await sink.dump();
263
308
  done({
@@ -349,12 +394,7 @@ export async function runInteractiveBashPty(
349
394
  },
350
395
  (err, chunk) => {
351
396
  if (err || !chunk) return;
352
- try {
353
- component.appendOutput(chunk);
354
- } catch {
355
- const normalizedChunk = normalizeCaptureChunk(chunk);
356
- component.appendOutput(normalizedChunk);
357
- }
397
+ component.appendOutput(chunk);
358
398
  const normalizedChunk = normalizeCaptureChunk(chunk);
359
399
  pendingChunks = pendingChunks.then(() => sink.push(normalizedChunk)).catch(() => {});
360
400
  tui.requestRender();
@@ -966,11 +966,12 @@ function countNonEmptyLines(text: string): number {
966
966
 
967
967
  /** Render fetch call (URL preview) */
968
968
  export function renderFetchCall(
969
- args: { url: string; timeout?: number; raw?: boolean },
969
+ args: { url?: string; timeout?: number; raw?: boolean },
970
970
  uiTheme: Theme = theme,
971
971
  ): Component {
972
- const domain = getDomain(args.url);
973
- const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50, "…");
972
+ const url = args.url ?? "";
973
+ const domain = getDomain(url);
974
+ const path = truncate(url.replace(/^https?:\/\/[^/]+/, ""), 50, "\u2026");
974
975
  const description = `${domain}${path ? ` ${path}` : ""}`.trim();
975
976
  const meta: string[] = [];
976
977
  if (args.raw) meta.push("raw");
package/src/tools/grep.ts CHANGED
@@ -104,7 +104,17 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
104
104
  const effectiveMultiline = multiline ?? patternHasNewline;
105
105
 
106
106
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
107
- const searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
107
+ let searchPath: string;
108
+ const internalRouter = this.session.internalRouter;
109
+ if (searchDir && internalRouter?.canHandle(searchDir)) {
110
+ const resource = await internalRouter.resolve(searchDir);
111
+ if (!resource.sourcePath) {
112
+ throw new ToolError(`Cannot grep internal URL without a backing file: ${searchDir}`);
113
+ }
114
+ searchPath = resource.sourcePath;
115
+ } else {
116
+ searchPath = resolveToCwd(searchDir || ".", this.session.cwd);
117
+ }
108
118
  const scopePath = (() => {
109
119
  const relative = path.relative(this.session.cwd, searchPath).replace(/\\/g, "/");
110
120
  return relative.length === 0 ? "." : relative;
@@ -209,11 +219,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
209
219
 
210
220
  const formatLine = (lineNumber: number, line: string, isMatch: boolean): string => {
211
221
  if (useHashLines) {
212
- const ref = `${lineNumber}:${computeLineHash(lineNumber, line)}`;
213
- return isMatch ? `>>${ref}|${line}` : ` ${ref}|${line}`;
222
+ const ref = `${lineNumber}#${computeLineHash(lineNumber, line)}`;
223
+ return isMatch ? `>>${ref}:${line}` : ` ${ref}:${line}`;
214
224
  }
215
225
  const padded = lineNumber.toString().padStart(lineWidth, " ");
216
- return isMatch ? `>>${padded}|${line}` : ` ${padded}|${line}`;
226
+ return isMatch ? `>>${padded}:${line}` : ` ${padded}:${line}`;
217
227
  };
218
228
 
219
229
  // Add context before
package/src/tools/read.ts CHANGED
@@ -772,7 +772,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
772
772
  const prependHashLines = (text: string, startNum: number): string => {
773
773
  const textLines = text.split("\n");
774
774
  return textLines
775
- .map((line, i) => `${startNum + i}:${computeLineHash(startNum + i, line)}|${line}`)
775
+ .map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`)
776
776
  .join("\n");
777
777
  };
778
778
  const formatText = (text: string, startNum: number): string => {
@@ -929,7 +929,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
929
929
  };
930
930
  const prependHashLines = (text: string, startNum: number): string => {
931
931
  const textLines = text.split("\n");
932
- return textLines.map((line, i) => `${startNum + i}:${computeLineHash(startNum + i, line)}|${line}`).join("\n");
932
+ return textLines.map((line, i) => `${startNum + i}#${computeLineHash(startNum + i, line)}:${line}`).join("\n");
933
933
  };
934
934
  const formatText = (text: string, startNum: number): string => {
935
935
  if (shouldAddHashLines) return prependHashLines(text, startNum);
package/src/tools/ssh.ts CHANGED
@@ -23,7 +23,7 @@ import { toolResult } from "./tool-result";
23
23
  import { DEFAULT_MAX_BYTES } from "./truncate";
24
24
 
25
25
  const sshSchema = Type.Object({
26
- host: Type.String({ description: "Host name from ssh.json or .ssh.json" }),
26
+ host: Type.String({ description: "Host name from managed SSH config or discovered ssh.json files" }),
27
27
  command: Type.String({ description: "Command to execute on the remote host" }),
28
28
  cwd: Type.Optional(Type.String({ description: "Remote working directory (optional)" })),
29
29
  timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 60)" })),
@@ -282,11 +282,11 @@ export function renderSearchResult(
282
282
 
283
283
  /** Render web search call (query preview) */
284
284
  export function renderSearchCall(
285
- args: { query: string; provider?: string; [key: string]: unknown },
285
+ args: { query?: string; provider?: string; [key: string]: unknown },
286
286
  theme: Theme,
287
287
  ): Component {
288
288
  const provider = args.provider ?? "auto";
289
- const query = truncateToWidth(args.query, 80);
289
+ const query = truncateToWidth(args.query ?? "", 80);
290
290
  const text = renderStatusLine({ icon: "pending", title: "Web Search", description: query, meta: [provider] }, theme);
291
291
  return new Text(text, 0, 0);
292
292
  }