@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.3
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.
- package/CHANGELOG.md +72 -0
- package/dist/cli.js +3621 -3579
- package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
- package/dist/types/advisor/advise-tool.d.ts +8 -4
- package/dist/types/advisor/config.d.ts +88 -0
- package/dist/types/advisor/index.d.ts +1 -0
- package/dist/types/advisor/runtime.d.ts +15 -1
- package/dist/types/advisor/transcript-recorder.d.ts +13 -2
- package/dist/types/advisor/watchdog.d.ts +20 -0
- package/dist/types/collab/guest.d.ts +29 -0
- package/dist/types/config/model-roles.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +113 -12
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/edit/index.d.ts +18 -0
- package/dist/types/edit/streaming.d.ts +30 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- package/dist/types/mcp/oauth-discovery.d.ts +0 -11
- package/dist/types/mcp/transports/index.d.ts +1 -0
- package/dist/types/mcp/transports/sse.d.ts +20 -0
- package/dist/types/modes/components/advisor-config.d.ts +59 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +9 -1
- package/dist/types/modes/components/settings-selector.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +30 -3
- package/dist/types/modes/components/status-line/types.d.ts +13 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +10 -4
- package/dist/types/modes/skill-command.d.ts +32 -0
- package/dist/types/modes/types.d.ts +7 -2
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +84 -12
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +32 -7
- package/dist/types/session/messages.test.d.ts +1 -0
- package/dist/types/session/session-entries.d.ts +31 -3
- package/dist/types/session/session-history-format.d.ts +6 -0
- package/dist/types/session/session-loader.d.ts +9 -1
- package/dist/types/session/session-manager.d.ts +6 -4
- package/dist/types/session/session-storage.d.ts +11 -0
- package/dist/types/session/session-title-slot.d.ts +19 -0
- package/dist/types/session/settings-stream-fn.d.ts +21 -0
- package/dist/types/session/turn-persistence.d.ts +88 -0
- package/dist/types/ssh/connection-manager.d.ts +47 -0
- package/dist/types/ssh/utils.d.ts +16 -0
- package/dist/types/task/executor.d.ts +3 -16
- package/dist/types/task/render.d.ts +0 -5
- package/dist/types/task/renderer.d.ts +13 -0
- package/dist/types/task/types.d.ts +16 -0
- package/dist/types/task/yield-assembly.d.ts +28 -0
- package/dist/types/tiny/text.d.ts +8 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/review.d.ts +6 -4
- package/dist/types/tools/ssh.d.ts +1 -1
- package/dist/types/tools/todo.d.ts +6 -0
- package/dist/types/tools/yield.d.ts +8 -3
- package/dist/types/utils/thinking-display.d.ts +4 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +438 -10
- package/src/advisor/__tests__/config.test.ts +173 -0
- package/src/advisor/advise-tool.ts +11 -6
- package/src/advisor/config.ts +256 -0
- package/src/advisor/index.ts +1 -0
- package/src/advisor/runtime.ts +77 -4
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- package/src/auto-thinking/classifier.ts +2 -2
- package/src/autoresearch/index.ts +7 -2
- package/src/cli/gc-cli.ts +17 -10
- package/src/collab/guest.ts +43 -7
- package/src/config/model-registry.ts +80 -18
- package/src/config/model-resolver.ts +5 -1
- package/src/config/model-roles.ts +3 -3
- package/src/config/settings-schema.ts +107 -8
- package/src/debug/index.ts +32 -7
- package/src/debug/log-viewer.ts +111 -53
- package/src/debug/raw-sse.ts +68 -48
- package/src/discovery/codex.ts +13 -5
- package/src/discovery/omp-extension-roots.ts +38 -13
- package/src/edit/hashline/diff.ts +57 -4
- package/src/edit/index.ts +21 -0
- package/src/edit/streaming.ts +170 -0
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/extensions/loader.ts +5 -3
- package/src/extensibility/extensions/wrapper.ts +9 -3
- package/src/extensibility/hooks/loader.ts +3 -3
- package/src/extensibility/hooks/tool-wrapper.ts +13 -4
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
- package/src/extensibility/plugins/manager.ts +76 -5
- package/src/extensibility/shared-events.ts +1 -0
- package/src/extensibility/tool-event-input.ts +23 -0
- package/src/extensibility/utils.ts +74 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/client.ts +3 -1
- package/src/mcp/manager.ts +12 -5
- package/src/mcp/oauth-discovery.ts +5 -29
- package/src/mcp/transports/http.ts +3 -1
- package/src/mcp/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +15 -6
- package/src/mnemopi/backend.ts +2 -2
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/components/advisor-config.ts +555 -0
- package/src/modes/components/advisor-message.ts +9 -2
- package/src/modes/components/agent-hub.ts +9 -4
- package/src/modes/components/assistant-message.ts +5 -5
- package/src/modes/components/index.ts +2 -0
- package/src/modes/components/model-selector.ts +79 -48
- package/src/modes/components/settings-selector.ts +1 -0
- package/src/modes/components/status-line/component.ts +145 -14
- package/src/modes/components/status-line/segments.ts +47 -22
- package/src/modes/components/status-line/types.ts +13 -1
- package/src/modes/components/tool-execution.ts +47 -6
- package/src/modes/controllers/command-controller.ts +23 -2
- package/src/modes/controllers/event-controller.ts +114 -11
- package/src/modes/controllers/extension-ui-controller.ts +1 -1
- package/src/modes/controllers/input-controller.ts +61 -61
- package/src/modes/controllers/selector-controller.ts +100 -9
- package/src/modes/interactive-mode.ts +65 -10
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +7 -2
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/modes/utils/ui-helpers.ts +46 -27
- package/src/prompts/agents/reviewer.md +11 -10
- package/src/prompts/review-custom-request.md +1 -2
- package/src/prompts/review-request.md +1 -2
- package/src/prompts/system/interrupted-thinking.md +7 -0
- package/src/prompts/system/recap-user.md +9 -0
- package/src/prompts/system/subagent-system-prompt.md +8 -5
- package/src/prompts/system/subagent-yield-reminder.md +6 -5
- package/src/prompts/system/system-prompt.md +0 -1
- package/src/prompts/tools/irc.md +2 -2
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +40 -50
- package/src/session/agent-session.ts +1139 -600
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +192 -21
- package/src/session/redis-session-storage.ts +49 -2
- package/src/session/session-entries.ts +39 -2
- package/src/session/session-history-format.ts +29 -2
- package/src/session/session-listing.ts +54 -24
- package/src/session/session-loader.ts +66 -3
- package/src/session/session-manager.ts +113 -19
- package/src/session/session-persistence.ts +96 -3
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/settings-stream-fn.ts +49 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/session/turn-persistence.ts +142 -0
- package/src/session/unexpected-stop-classifier.ts +2 -2
- package/src/slash-commands/builtin-registry.ts +16 -3
- package/src/slash-commands/helpers/mcp.ts +2 -1
- package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
- package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
- package/src/ssh/connection-manager.ts +139 -12
- package/src/ssh/file-transfer.ts +23 -18
- package/src/ssh/ssh-executor.ts +2 -13
- package/src/ssh/utils.ts +19 -0
- package/src/task/executor.ts +21 -23
- package/src/task/render.ts +162 -20
- package/src/task/renderer.ts +14 -0
- package/src/task/types.ts +17 -0
- package/src/task/yield-assembly.ts +207 -0
- package/src/tiny/models.ts +8 -6
- package/src/tiny/text.ts +37 -7
- package/src/tools/ask.ts +55 -4
- package/src/tools/image-gen.ts +2 -1
- package/src/tools/render-utils.ts +2 -0
- package/src/tools/renderers.ts +8 -2
- package/src/tools/review.ts +17 -7
- package/src/tools/ssh.ts +8 -4
- package/src/tools/todo.ts +17 -1
- package/src/tools/tts.ts +2 -1
- package/src/tools/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/providers/tavily.ts +36 -19
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type SessionStorageBackend,
|
|
4
4
|
type SessionStorageIndexEntry,
|
|
5
5
|
} from "./indexed-session-storage";
|
|
6
|
+
import type { SessionTitleUpdate } from "./session-title-slot";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Supported `bun:sql` adapter dialects. `Bun.SQL` reports this string on
|
|
@@ -55,10 +56,14 @@ export interface SqlSessionStorageOptions {
|
|
|
55
56
|
|
|
56
57
|
interface DialectQueries {
|
|
57
58
|
createTable: string;
|
|
59
|
+
/** Add title metadata columns to existing tables created before title fields existed. */
|
|
60
|
+
addTitleColumns: readonly string[];
|
|
58
61
|
/** Insert or replace the full content for `path`. Used for `writeText`/`flags="w"` truncate. */
|
|
59
62
|
upsertReplace: string;
|
|
60
63
|
/** Insert if missing; otherwise append the new chunk to existing content. Used for `writeLine`. */
|
|
61
64
|
upsertAppend: string;
|
|
65
|
+
/** Update indexed title metadata without rewriting the JSONL body. */
|
|
66
|
+
updateTitle: string;
|
|
62
67
|
/** Delete a single row by path. */
|
|
63
68
|
delete: string;
|
|
64
69
|
/** Move a row from one path to another (caller deletes any conflicting destination first). */
|
|
@@ -75,6 +80,9 @@ interface IndexRow {
|
|
|
75
80
|
path: string;
|
|
76
81
|
byte_len: number | bigint | string;
|
|
77
82
|
mtime_ms: number | bigint | string;
|
|
83
|
+
title?: string | null;
|
|
84
|
+
title_source?: string | null;
|
|
85
|
+
title_updated_at?: string | null;
|
|
78
86
|
}
|
|
79
87
|
|
|
80
88
|
interface ContentRow {
|
|
@@ -119,17 +127,26 @@ function buildQueries(adapter: SqlSessionStorageAdapter, table: string): Dialect
|
|
|
119
127
|
`CREATE TABLE IF NOT EXISTS ${table} (` +
|
|
120
128
|
`path VARCHAR(512) NOT NULL PRIMARY KEY, ` +
|
|
121
129
|
`content LONGTEXT NOT NULL, ` +
|
|
122
|
-
`mtime_ms BIGINT NOT NULL` +
|
|
130
|
+
`mtime_ms BIGINT NOT NULL, ` +
|
|
131
|
+
`title TEXT NULL, ` +
|
|
132
|
+
`title_source VARCHAR(16) NULL, ` +
|
|
133
|
+
`title_updated_at VARCHAR(64) NULL` +
|
|
123
134
|
`) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`,
|
|
135
|
+
addTitleColumns: [
|
|
136
|
+
`ALTER TABLE ${table} ADD COLUMN title TEXT NULL`,
|
|
137
|
+
`ALTER TABLE ${table} ADD COLUMN title_source VARCHAR(16) NULL`,
|
|
138
|
+
`ALTER TABLE ${table} ADD COLUMN title_updated_at VARCHAR(64) NULL`,
|
|
139
|
+
],
|
|
124
140
|
upsertReplace:
|
|
125
|
-
`INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
|
|
126
|
-
`ON DUPLICATE KEY UPDATE content = VALUES(content), mtime_ms = VALUES(mtime_ms)`,
|
|
141
|
+
`INSERT INTO ${table} (path, content, mtime_ms, title, title_source, title_updated_at) VALUES (?, ?, ?, ?, ?, ?) ` +
|
|
142
|
+
`ON DUPLICATE KEY UPDATE content = VALUES(content), mtime_ms = VALUES(mtime_ms), title = VALUES(title), title_source = VALUES(title_source), title_updated_at = VALUES(title_updated_at)`,
|
|
127
143
|
upsertAppend:
|
|
128
144
|
`INSERT INTO ${table} (path, content, mtime_ms) VALUES (?, ?, ?) ` +
|
|
129
145
|
`ON DUPLICATE KEY UPDATE content = CONCAT(content, VALUES(content)), mtime_ms = VALUES(mtime_ms)`,
|
|
146
|
+
updateTitle: `UPDATE ${table} SET title = ?, title_source = ?, title_updated_at = ?, mtime_ms = ? WHERE path = ?`,
|
|
130
147
|
delete: `DELETE FROM ${table} WHERE path = ?`,
|
|
131
148
|
rename: `UPDATE ${table} SET path = ?, mtime_ms = ? WHERE path = ?`,
|
|
132
|
-
loadIndex: `SELECT path, mtime_ms, length(content) AS byte_len FROM ${table}`,
|
|
149
|
+
loadIndex: `SELECT path, mtime_ms, length(content) AS byte_len, title, title_source, title_updated_at FROM ${table}`,
|
|
133
150
|
readFull: `SELECT content AS content FROM ${table} WHERE path = ?`,
|
|
134
151
|
readSlices:
|
|
135
152
|
`SELECT substring(cast(content AS binary), 1, ?) AS head, ` +
|
|
@@ -157,19 +174,28 @@ function buildQueries(adapter: SqlSessionStorageAdapter, table: string): Dialect
|
|
|
157
174
|
`CREATE TABLE IF NOT EXISTS ${table} (` +
|
|
158
175
|
`path TEXT PRIMARY KEY, ` +
|
|
159
176
|
`content TEXT NOT NULL, ` +
|
|
160
|
-
`mtime_ms ${mtimeType} NOT NULL` +
|
|
177
|
+
`mtime_ms ${mtimeType} NOT NULL, ` +
|
|
178
|
+
`title TEXT, ` +
|
|
179
|
+
`title_source TEXT, ` +
|
|
180
|
+
`title_updated_at TEXT` +
|
|
161
181
|
`)`,
|
|
182
|
+
addTitleColumns: [
|
|
183
|
+
`ALTER TABLE ${table} ADD COLUMN title TEXT`,
|
|
184
|
+
`ALTER TABLE ${table} ADD COLUMN title_source TEXT`,
|
|
185
|
+
`ALTER TABLE ${table} ADD COLUMN title_updated_at TEXT`,
|
|
186
|
+
],
|
|
162
187
|
upsertReplace:
|
|
163
|
-
`INSERT INTO ${table} (path, content, mtime_ms) ` +
|
|
164
|
-
`VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
|
|
165
|
-
`ON CONFLICT (path) DO UPDATE SET content = excluded.content, mtime_ms = excluded.mtime_ms`,
|
|
188
|
+
`INSERT INTO ${table} (path, content, mtime_ms, title, title_source, title_updated_at) ` +
|
|
189
|
+
`VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}, ${placeholder(4)}, ${placeholder(5)}, ${placeholder(6)}) ` +
|
|
190
|
+
`ON CONFLICT (path) DO UPDATE SET content = excluded.content, mtime_ms = excluded.mtime_ms, title = excluded.title, title_source = excluded.title_source, title_updated_at = excluded.title_updated_at`,
|
|
166
191
|
upsertAppend:
|
|
167
192
|
`INSERT INTO ${table} (path, content, mtime_ms) ` +
|
|
168
193
|
`VALUES (${placeholder(1)}, ${placeholder(2)}, ${placeholder(3)}) ` +
|
|
169
194
|
`ON CONFLICT (path) DO UPDATE SET content = ${tableQualifier} || excluded.content, mtime_ms = excluded.mtime_ms`,
|
|
195
|
+
updateTitle: `UPDATE ${table} SET title = ${placeholder(1)}, title_source = ${placeholder(2)}, title_updated_at = ${placeholder(3)}, mtime_ms = ${placeholder(4)} WHERE path = ${placeholder(5)}`,
|
|
170
196
|
delete: `DELETE FROM ${table} WHERE path = ${placeholder(1)}`,
|
|
171
197
|
rename: `UPDATE ${table} SET path = ${placeholder(1)}, mtime_ms = ${placeholder(2)} WHERE path = ${placeholder(3)}`,
|
|
172
|
-
loadIndex: `SELECT path, mtime_ms, ${byteLengthExpr} AS byte_len FROM ${table}`,
|
|
198
|
+
loadIndex: `SELECT path, mtime_ms, ${byteLengthExpr} AS byte_len, title, title_source, title_updated_at FROM ${table}`,
|
|
173
199
|
readFull: `SELECT content AS content FROM ${table} WHERE path = ${placeholder(1)}`,
|
|
174
200
|
readSlices,
|
|
175
201
|
};
|
|
@@ -180,6 +206,13 @@ function rowNumber(value: number | bigint | string): number {
|
|
|
180
206
|
if (typeof value === "bigint") return Number(value);
|
|
181
207
|
return Number.parseInt(value, 10);
|
|
182
208
|
}
|
|
209
|
+
function rowTitleSource(value: string | null | undefined): SessionTitleUpdate["source"] | undefined {
|
|
210
|
+
return value === "auto" || value === "user" ? value : undefined;
|
|
211
|
+
}
|
|
212
|
+
function isDuplicateColumnError(error: unknown): boolean {
|
|
213
|
+
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
|
214
|
+
return message.includes("duplicate column") || message.includes("already exists");
|
|
215
|
+
}
|
|
183
216
|
|
|
184
217
|
function decodeSqlBytes(value: unknown): string {
|
|
185
218
|
if (value === null || value === undefined) return "";
|
|
@@ -260,6 +293,13 @@ class SqlSessionStorageBackend implements SessionStorageBackend {
|
|
|
260
293
|
async init(): Promise<void> {
|
|
261
294
|
if (this.#createTable) {
|
|
262
295
|
await this.#client.unsafe(this.#q.createTable);
|
|
296
|
+
for (const query of this.#q.addTitleColumns) {
|
|
297
|
+
try {
|
|
298
|
+
await this.#client.unsafe(query);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (!isDuplicateColumnError(err)) throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
263
303
|
}
|
|
264
304
|
}
|
|
265
305
|
|
|
@@ -269,6 +309,9 @@ class SqlSessionStorageBackend implements SessionStorageBackend {
|
|
|
269
309
|
path: row.path,
|
|
270
310
|
size: rowNumber(row.byte_len),
|
|
271
311
|
mtimeMs: rowNumber(row.mtime_ms),
|
|
312
|
+
title: row.title ?? undefined,
|
|
313
|
+
titleSource: rowTitleSource(row.title_source),
|
|
314
|
+
titleUpdatedAt: row.title_updated_at ?? undefined,
|
|
272
315
|
}));
|
|
273
316
|
}
|
|
274
317
|
|
|
@@ -289,8 +332,25 @@ class SqlSessionStorageBackend implements SessionStorageBackend {
|
|
|
289
332
|
return [decodeSqlBytes(row.head), decodeSqlBytes(row.tail)];
|
|
290
333
|
}
|
|
291
334
|
|
|
292
|
-
async writeFull(path: string, content: string, mtimeMs: number): Promise<void> {
|
|
293
|
-
await this.#client.unsafe(this.#q.upsertReplace, [
|
|
335
|
+
async writeFull(path: string, content: string, mtimeMs: number, title?: SessionTitleUpdate): Promise<void> {
|
|
336
|
+
await this.#client.unsafe(this.#q.upsertReplace, [
|
|
337
|
+
path,
|
|
338
|
+
content,
|
|
339
|
+
mtimeMs,
|
|
340
|
+
title?.title ?? null,
|
|
341
|
+
title?.source ?? null,
|
|
342
|
+
title?.updatedAt ?? null,
|
|
343
|
+
]);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async updateSessionTitle(path: string, title: SessionTitleUpdate, mtimeMs: number): Promise<void> {
|
|
347
|
+
await this.#client.unsafe(this.#q.updateTitle, [
|
|
348
|
+
title.title ?? null,
|
|
349
|
+
title.source ?? null,
|
|
350
|
+
title.updatedAt,
|
|
351
|
+
mtimeMs,
|
|
352
|
+
path,
|
|
353
|
+
]);
|
|
294
354
|
}
|
|
295
355
|
|
|
296
356
|
async append(path: string, line: string, mtimeMs: number): Promise<void> {
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers that share one cheap, structural identity for messages — both during
|
|
3
|
+
* incremental persistence and for the mid-run-compaction ordering check.
|
|
4
|
+
*
|
|
5
|
+
* Previously `AgentSession` carried two near-duplicate routines
|
|
6
|
+
* (`#sessionMessagesReferToSameTurn` + `#messageValueSignature`) that
|
|
7
|
+
* reconstructed the branch path on every check (O(n²) `unshift`) and
|
|
8
|
+
* `JSON.stringify`-compared the full message content on every pairwise hit.
|
|
9
|
+
* Long-running sessions with many subagents fired this thousands of times per
|
|
10
|
+
* minute and froze the TUI loop (see issue #3629). The persistence key already
|
|
11
|
+
* encodes a stable logical identity — timestamp + role-specific discriminators
|
|
12
|
+
* — so the structural compare is now the rare collision tiebreaker (e.g. two
|
|
13
|
+
* provider responses at the same millisecond with `undefined` responseId),
|
|
14
|
+
* not the load-bearing check.
|
|
15
|
+
*
|
|
16
|
+
* The helpers here keep that identity in one place and expose the planner so
|
|
17
|
+
* the persistence-ordering logic is unit-testable without standing up an
|
|
18
|
+
* `AgentSession`.
|
|
19
|
+
*/
|
|
20
|
+
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Stable identity for messages that pass through {@link AgentSession}'s
|
|
24
|
+
* incremental persistence path.
|
|
25
|
+
*
|
|
26
|
+
* The discriminators chosen per role are precisely the fields that uniquely
|
|
27
|
+
* identify a single logical message instance:
|
|
28
|
+
*
|
|
29
|
+
* - `assistant` — timestamp + provider + model + responseId + stopReason
|
|
30
|
+
* (responseId is the canonical provider-side id when available; the rest
|
|
31
|
+
* disambiguate when it is not, e.g. local/dev models).
|
|
32
|
+
* - `toolResult` — timestamp + toolCallId + toolName (toolCallId is unique
|
|
33
|
+
* per execution; toolName guards against synthetic reuse).
|
|
34
|
+
* - `user` / `developer` — timestamp + attribution (attribution distinguishes
|
|
35
|
+
* user-typed vs hook-injected at the same wall-clock millisecond).
|
|
36
|
+
* - `fileMention` — timestamp.
|
|
37
|
+
*
|
|
38
|
+
* Returns `undefined` for message roles that are not persisted through this
|
|
39
|
+
* path (e.g. `hookMessage`, `custom`, `bashExecution`) — those follow other
|
|
40
|
+
* append paths in `SessionManager`.
|
|
41
|
+
*/
|
|
42
|
+
export function sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
|
|
43
|
+
switch (message.role) {
|
|
44
|
+
case "assistant":
|
|
45
|
+
return [
|
|
46
|
+
"assistant",
|
|
47
|
+
message.timestamp,
|
|
48
|
+
message.provider,
|
|
49
|
+
message.model,
|
|
50
|
+
message.responseId ?? "",
|
|
51
|
+
message.stopReason,
|
|
52
|
+
].join(":");
|
|
53
|
+
case "toolResult":
|
|
54
|
+
return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
|
|
55
|
+
case "user":
|
|
56
|
+
case "developer":
|
|
57
|
+
return `${message.role}:${message.timestamp}:${message.attribution ?? ""}`;
|
|
58
|
+
case "fileMention":
|
|
59
|
+
return `fileMention:${message.timestamp}`;
|
|
60
|
+
default:
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Slow-path content equality check used when two messages collide on
|
|
67
|
+
* {@link sessionMessagePersistenceKey}. Only the role's content fields are
|
|
68
|
+
* compared (no timestamps, no metadata) because the key already pinned all of
|
|
69
|
+
* those down.
|
|
70
|
+
*
|
|
71
|
+
* Most calls into the persistence path never reach this — keys are unique
|
|
72
|
+
* enough in production that the snapshot lookup short-circuits at the key
|
|
73
|
+
* level. Restoring the structural compare here preserves the pre-#3629
|
|
74
|
+
* contract that two messages with the same metadata BUT different content are
|
|
75
|
+
* distinct (e.g. two assistant turns with `undefined` responseId emitted in
|
|
76
|
+
* the same wall-clock millisecond, which is exactly how the in-memory test
|
|
77
|
+
* harness crafts streamed responses).
|
|
78
|
+
*/
|
|
79
|
+
export function sameMessageContent(left: AgentMessage, right: AgentMessage): boolean {
|
|
80
|
+
if (left === right) return true;
|
|
81
|
+
if (left.role !== right.role) return false;
|
|
82
|
+
// `JSON.stringify` is the slow-path serializer here on purpose: nothing on
|
|
83
|
+
// the hot persistence-check path reaches it (key lookup short-circuits
|
|
84
|
+
// first), so a stable lexicographic compare beats hand-rolling structural
|
|
85
|
+
// equality for content arrays that mix text / tool blocks / file refs.
|
|
86
|
+
const leftRaw = left.role === "fileMention" ? left.files : "content" in left ? left.content : undefined;
|
|
87
|
+
const rightRaw = right.role === "fileMention" ? right.files : "content" in right ? right.content : undefined;
|
|
88
|
+
if (leftRaw === undefined || rightRaw === undefined) return false;
|
|
89
|
+
return (JSON.stringify(leftRaw) ?? "undefined") === (JSON.stringify(rightRaw) ?? "undefined");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Outcome of {@link planTurnPersistence}.
|
|
94
|
+
*
|
|
95
|
+
* `ok` lists the turn-message indices that still need to be appended (in
|
|
96
|
+
* order). `out-of-order` reports the first message whose later sibling is
|
|
97
|
+
* already persisted — the caller bails so it does not silently splice a
|
|
98
|
+
* stale message between newer entries on the live branch.
|
|
99
|
+
*/
|
|
100
|
+
export type TurnPersistencePlan =
|
|
101
|
+
| { kind: "ok"; toPersist: readonly number[] }
|
|
102
|
+
| { kind: "out-of-order"; messageIndex: number };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Decide what to do with a turn's messages relative to what's already on the
|
|
106
|
+
* branch, in a single pass over the pre-computed keys.
|
|
107
|
+
*
|
|
108
|
+
* @param turnKeys persistence keys for each turn message, in the order the
|
|
109
|
+
* agent loop emitted them. `undefined` slots represent messages with no
|
|
110
|
+
* persistence key (skipped silently).
|
|
111
|
+
* @param persistedKeys the snapshot of persistence keys currently on the
|
|
112
|
+
* branch (built once per call from {@link sessionMessagePersistenceKey} for
|
|
113
|
+
* each persisted message entry).
|
|
114
|
+
*
|
|
115
|
+
* The check is O(n²) over turn messages — but `n` here is the size of one
|
|
116
|
+
* turn (a handful of tool results), not the size of the branch. That's the
|
|
117
|
+
* point of this refactor: the expensive O(branch) work happens exactly once,
|
|
118
|
+
* inside the caller's snapshot loop, not per-comparison.
|
|
119
|
+
*/
|
|
120
|
+
export function planTurnPersistence(
|
|
121
|
+
turnKeys: readonly (string | undefined)[],
|
|
122
|
+
persistedKeys: ReadonlySet<string>,
|
|
123
|
+
): TurnPersistencePlan {
|
|
124
|
+
const toPersist: number[] = [];
|
|
125
|
+
for (let index = 0; index < turnKeys.length; index++) {
|
|
126
|
+
const key = turnKeys[index];
|
|
127
|
+
// Slots without a persistence key (non-persistent roles like `custom` /
|
|
128
|
+
// `hookMessage`) take other branches in `SessionManager` — they are not
|
|
129
|
+
// our responsibility to append, and they cannot violate ordering because
|
|
130
|
+
// they have no identity on the branch.
|
|
131
|
+
if (key === undefined) continue;
|
|
132
|
+
if (persistedKeys.has(key)) continue;
|
|
133
|
+
for (let later = index + 1; later < turnKeys.length; later++) {
|
|
134
|
+
const laterKey = turnKeys[later];
|
|
135
|
+
if (laterKey !== undefined && persistedKeys.has(laterKey)) {
|
|
136
|
+
return { kind: "out-of-order", messageIndex: index };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
toPersist.push(index);
|
|
140
|
+
}
|
|
141
|
+
return { kind: "ok", toPersist };
|
|
142
|
+
}
|
|
@@ -64,10 +64,10 @@ export async function classifyUnexpectedStop(
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
async function classifyOnline(text: string, deps: ClassifyUnexpectedStopDeps): Promise<boolean | undefined> {
|
|
67
|
-
const resolved = resolveRoleSelection(["smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
|
|
67
|
+
const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable(), deps.registry);
|
|
68
68
|
const model = resolved?.model;
|
|
69
69
|
if (!model) {
|
|
70
|
-
throw new Error("unexpected-stop: no smol model available for classification");
|
|
70
|
+
throw new Error("unexpected-stop: no tiny/smol model available for classification");
|
|
71
71
|
}
|
|
72
72
|
const apiKey = await deps.registry.getApiKey(model, deps.sessionId);
|
|
73
73
|
if (!apiKey) {
|
|
@@ -438,16 +438,18 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
438
438
|
name: "advisor",
|
|
439
439
|
description: "Toggle the advisor (a second model that reviews each turn and injects notes)",
|
|
440
440
|
acpDescription: "Toggle advisor",
|
|
441
|
-
acpInputHint: "[on|off|status|dump [raw]]",
|
|
441
|
+
acpInputHint: "[on|off|status|dump [raw]|configure]",
|
|
442
442
|
subcommands: [
|
|
443
443
|
{ name: "on", description: "Enable the advisor" },
|
|
444
444
|
{ name: "off", description: "Disable the advisor" },
|
|
445
445
|
{ name: "status", description: "Show advisor status" },
|
|
446
446
|
{ name: "dump", description: "Copy the advisor's transcript to clipboard", usage: "[raw]" },
|
|
447
|
+
{ name: "configure", description: "Open the advisor configuration editor (TUI)" },
|
|
447
448
|
],
|
|
448
449
|
allowArgs: true,
|
|
449
450
|
getTuiAutocompleteDescription: runtime => {
|
|
450
451
|
const stats = runtime.ctx.session.getAdvisorStats();
|
|
452
|
+
if (stats.active && stats.advisors.length > 1) return `Advisor: on (${stats.advisors.length} advisors)`;
|
|
451
453
|
if (stats.active && stats.model) return `Advisor: on (${stats.model.provider}/${stats.model.id})`;
|
|
452
454
|
if (stats.configured) return "Advisor: configured, no model";
|
|
453
455
|
return "Advisor: off";
|
|
@@ -488,7 +490,13 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
488
490
|
await runtime.output(text ?? "Advisor is not active for this session.");
|
|
489
491
|
return commandConsumed();
|
|
490
492
|
}
|
|
491
|
-
|
|
493
|
+
if (verb === "configure") {
|
|
494
|
+
await runtime.output(
|
|
495
|
+
"/advisor configure opens an interactive editor and is only available in the interactive TUI.",
|
|
496
|
+
);
|
|
497
|
+
return commandConsumed();
|
|
498
|
+
}
|
|
499
|
+
return usage("Usage: /advisor [on|off|status|dump [raw]|configure]", runtime);
|
|
492
500
|
},
|
|
493
501
|
handleTui: async (command, runtime) => {
|
|
494
502
|
const { verb, rest } = parseSubcommand(command.args);
|
|
@@ -533,7 +541,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
533
541
|
runtime.ctx.editor.setText("");
|
|
534
542
|
return;
|
|
535
543
|
}
|
|
536
|
-
|
|
544
|
+
if (verb === "configure") {
|
|
545
|
+
runtime.ctx.showAdvisorConfigure();
|
|
546
|
+
runtime.ctx.editor.setText("");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]|configure]");
|
|
537
550
|
runtime.ctx.editor.setText("");
|
|
538
551
|
},
|
|
539
552
|
},
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
1
2
|
import { getMCPConfigPath, logger } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import { connectToServer, disconnectServer, listPrompts, listResources, listTools } from "../../mcp/client";
|
|
3
4
|
import {
|
|
@@ -350,7 +351,7 @@ async function handleSmitherySearchCommand(rest: string, runtime: SlashCommandRu
|
|
|
350
351
|
return commandConsumed();
|
|
351
352
|
} catch (err) {
|
|
352
353
|
const message = errorMessage(err);
|
|
353
|
-
if (
|
|
354
|
+
if (AIError.is(AIError.classify(err), AIError.Flag.AuthFailed)) {
|
|
354
355
|
return usage(
|
|
355
356
|
"Smithery authentication required. Run /mcp smithery-login in the TUI client or add an API key to ~/.omp/agent/smithery.json.",
|
|
356
357
|
runtime,
|
|
@@ -2,7 +2,18 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { getRemoteHostDir } from "@oh-my-pi/pi-utils";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildRemoteCommand,
|
|
7
|
+
extractProbePayload,
|
|
8
|
+
findProbeMarker,
|
|
9
|
+
getHostInfo,
|
|
10
|
+
HOST_PROBE_MARKER,
|
|
11
|
+
osFromUname,
|
|
12
|
+
parseHostInfo,
|
|
13
|
+
type SSHConnectionTarget,
|
|
14
|
+
type SSHHostShell,
|
|
15
|
+
TRANSFER_PROBE_MARKER,
|
|
16
|
+
} from "../connection-manager";
|
|
6
17
|
import { buildSshTarget, sanitizeHostName } from "../utils";
|
|
7
18
|
|
|
8
19
|
const TARGET: SSHConnectionTarget = { name: "h", host: "h" };
|
|
@@ -67,3 +78,114 @@ describe("ssh host shell classification", () => {
|
|
|
67
78
|
}
|
|
68
79
|
});
|
|
69
80
|
});
|
|
81
|
+
|
|
82
|
+
describe("extractProbePayload (host probe framing)", () => {
|
|
83
|
+
it("returns the text after the first marker line, ignoring login banners", async () => {
|
|
84
|
+
// Real-world failure shape: noisy dotfiles print a banner before the
|
|
85
|
+
// echo we asked for, so the legacy first-line parser would have read
|
|
86
|
+
// `Last login: ...` and classified the host as unknown (#3719).
|
|
87
|
+
const stdout = [
|
|
88
|
+
"Last login: Wed Mar 19 09:14:22 2025 from 10.0.0.1",
|
|
89
|
+
"Welcome to fancybox 1.0",
|
|
90
|
+
`${HOST_PROBE_MARKER}linux-gnu|/bin/bash|5.2.21`,
|
|
91
|
+
].join("\n");
|
|
92
|
+
expect(extractProbePayload(stdout, "")).toBe("linux-gnu|/bin/bash|5.2.21");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("falls back to stderr when the payload only shows up there", async () => {
|
|
96
|
+
// Some shells redirect every echo to stderr after a dotfile error; the
|
|
97
|
+
// parser needs to recover the marker line from either stream.
|
|
98
|
+
const stderr = `noise\n${HOST_PROBE_MARKER}darwin|/bin/zsh|\n`;
|
|
99
|
+
expect(extractProbePayload("", stderr)).toBe("darwin|/bin/zsh|");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns null when no marker line is present", async () => {
|
|
103
|
+
expect(extractProbePayload("just login banner\n", "and stderr noise\n")).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("findProbeMarker (transfer-shell probe recovery)", () => {
|
|
108
|
+
it("returns the tail after the marker when it appears in stdout", () => {
|
|
109
|
+
// Happy path: `sh -lc 'printf "PI_TRANSFER_OK|"; uname -s'` lands in
|
|
110
|
+
// stdout. The tail is the uname output the caller uses to refine OS.
|
|
111
|
+
const stdout = `${TRANSFER_PROBE_MARKER}Linux\n`;
|
|
112
|
+
expect(findProbeMarker(stdout, "", TRANSFER_PROBE_MARKER)).toBe("Linux\n");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("falls back to stderr when a broken dotfile swaps fd 1/2", () => {
|
|
116
|
+
// Some remotes have dotfiles that redirect every shell write to stderr.
|
|
117
|
+
// The transfer probe must still recognize the marker so ssh:// doesn't
|
|
118
|
+
// refuse a POSIX-capable host (#3722 review).
|
|
119
|
+
const stderr = `dotfile noise\n${TRANSFER_PROBE_MARKER}Darwin\n`;
|
|
120
|
+
expect(findProbeMarker("", stderr, TRANSFER_PROBE_MARKER)).toBe("Darwin\n");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("prefers stdout over stderr when the marker is in both", () => {
|
|
124
|
+
// Order matters: stdout is the canonical path, stderr is the rescue.
|
|
125
|
+
// A reordering bug would silently use stale stderr fragments first.
|
|
126
|
+
const stdout = `${TRANSFER_PROBE_MARKER}Linux`;
|
|
127
|
+
const stderr = `${TRANSFER_PROBE_MARKER}stale`;
|
|
128
|
+
expect(findProbeMarker(stdout, stderr, TRANSFER_PROBE_MARKER)).toBe("Linux");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns null when the marker is in neither stream", () => {
|
|
132
|
+
expect(findProbeMarker("noise", "more noise", TRANSFER_PROBE_MARKER)).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("osFromUname (transfer-shell probe OS recovery)", () => {
|
|
137
|
+
it("classifies common POSIX uname payloads", () => {
|
|
138
|
+
// The markerless host-info fallback uses the transfer-shell probe's
|
|
139
|
+
// uname output to avoid returning a durable `os: "unknown"` when csh/tcsh
|
|
140
|
+
// killed the first marker probe before it could echo anything (#3722 review).
|
|
141
|
+
expect(osFromUname("Linux")).toBe("linux");
|
|
142
|
+
expect(osFromUname("GNU/Linux")).toBe("linux");
|
|
143
|
+
expect(osFromUname("Darwin")).toBe("macos");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("classifies Windows compat unames as windows so ssh:// still refuses them", () => {
|
|
147
|
+
expect(osFromUname("MINGW64_NT-10.0")).toBe("windows");
|
|
148
|
+
expect(osFromUname("MSYS_NT-10.0")).toBe("windows");
|
|
149
|
+
expect(osFromUname("CYGWIN_NT-10.0")).toBe("windows");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns undefined when uname is not recognized", () => {
|
|
153
|
+
expect(osFromUname("")).toBeUndefined();
|
|
154
|
+
expect(osFromUname("SunOS")).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("parseHostInfo transferShell handling", () => {
|
|
159
|
+
it("round-trips a verified transferShell value", () => {
|
|
160
|
+
// Cache writers persist `transferShell` so callers don't re-probe
|
|
161
|
+
// every session; parseHostInfo must thread it back through (#3719).
|
|
162
|
+
const parsed = parseHostInfo({
|
|
163
|
+
version: 4,
|
|
164
|
+
os: "linux",
|
|
165
|
+
shell: "unknown",
|
|
166
|
+
transferShell: "bash",
|
|
167
|
+
compatEnabled: false,
|
|
168
|
+
});
|
|
169
|
+
expect(parsed?.transferShell).toBe("bash");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("drops a transferShell value outside the sh/bash/zsh allowlist", () => {
|
|
173
|
+
// Anything we couldn't have probed (fish, csh, garbage) must not slip
|
|
174
|
+
// into the cache and bypass the ssh:// transfer guard.
|
|
175
|
+
const parsed = parseHostInfo({
|
|
176
|
+
version: 4,
|
|
177
|
+
os: "linux",
|
|
178
|
+
shell: "sh",
|
|
179
|
+
transferShell: "fish",
|
|
180
|
+
compatEnabled: false,
|
|
181
|
+
});
|
|
182
|
+
expect(parsed?.transferShell).toBeUndefined();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("returns transferShell undefined when the field is missing", () => {
|
|
186
|
+
// A pre-v4 cache file lacks transferShell entirely; the parsed value
|
|
187
|
+
// must be undefined so shouldRefreshHostInfo treats it as stale.
|
|
188
|
+
const parsed = parseHostInfo({ version: 3, os: "linux", shell: "sh", compatEnabled: false });
|
|
189
|
+
expect(parsed?.transferShell).toBeUndefined();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
2
|
import type { SSHConnectionTarget } from "../connection-manager";
|
|
3
3
|
import * as connectionManager from "../connection-manager";
|
|
4
|
-
import { readRemoteFile, writeRemoteFile } from "../file-transfer";
|
|
4
|
+
import { listRemoteDir, readRemoteFile, statRemotePath, writeRemoteFile } from "../file-transfer";
|
|
5
5
|
|
|
6
6
|
describe("ssh file-transfer POSIX guard", () => {
|
|
7
7
|
afterEach(() => {
|
|
@@ -13,7 +13,7 @@ describe("ssh file-transfer POSIX guard", () => {
|
|
|
13
13
|
// without opening a real SSH connection and before any command is spawned.
|
|
14
14
|
const ensureConnectionSpy = vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
15
15
|
const ensureHostInfoSpy = vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
16
|
-
version:
|
|
16
|
+
version: 4,
|
|
17
17
|
os: "windows",
|
|
18
18
|
shell: "powershell",
|
|
19
19
|
compatEnabled: false,
|
|
@@ -27,42 +27,79 @@ describe("ssh file-transfer POSIX guard", () => {
|
|
|
27
27
|
expect(ensureHostInfoSpy).toHaveBeenCalled();
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
it("rejects a non-
|
|
31
|
-
//
|
|
32
|
-
//
|
|
30
|
+
it("rejects a non-Windows remote with no verified transferShell", async () => {
|
|
31
|
+
// No transferShell means the capability probe never confirmed any of
|
|
32
|
+
// sh/bash/zsh works. The guard refuses regardless of `shell` because the
|
|
33
|
+
// real ssh:// contract is "did we verify a POSIX shell works", not
|
|
34
|
+
// "what name did the login shell self-report" (#3719).
|
|
33
35
|
vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
34
36
|
vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
35
|
-
version:
|
|
37
|
+
version: 4,
|
|
36
38
|
os: "linux",
|
|
37
39
|
shell: "unknown",
|
|
38
40
|
compatEnabled: false,
|
|
39
41
|
});
|
|
40
|
-
const target: SSHConnectionTarget = { name: "
|
|
41
|
-
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/
|
|
42
|
-
await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(
|
|
42
|
+
const target: SSHConnectionTarget = { name: "noshell", host: "noshell" };
|
|
43
|
+
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/no verified POSIX shell/);
|
|
44
|
+
await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(
|
|
45
|
+
/no verified POSIX shell/,
|
|
46
|
+
);
|
|
43
47
|
});
|
|
44
48
|
|
|
45
|
-
it("
|
|
46
|
-
//
|
|
47
|
-
//
|
|
49
|
+
it("dispatches transfer commands through the verified transferShell, not the login shell", async () => {
|
|
50
|
+
// The bug fix: if the login shell is fish/csh/tcsh, the legacy guard
|
|
51
|
+
// would refuse the host — but allowing it isn't enough on its own.
|
|
52
|
+
// OpenSSH still hands our snippets to `$SHELL -c`, so a fish login
|
|
53
|
+
// shell would choke on `if [ … ]; then …`. Every transfer command
|
|
54
|
+
// must be wrapped in `<transferShell> -c '…'` to force parsing
|
|
55
|
+
// under the shell we verified can run it (#3719).
|
|
48
56
|
vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
49
57
|
vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
50
|
-
version:
|
|
58
|
+
version: 4,
|
|
51
59
|
os: "linux",
|
|
52
|
-
shell
|
|
60
|
+
// Login shell is fish; only `transferShell` indicates a working POSIX shell.
|
|
61
|
+
shell: "unknown",
|
|
62
|
+
transferShell: "bash",
|
|
53
63
|
compatEnabled: false,
|
|
54
64
|
});
|
|
55
65
|
const buildSpy = vi
|
|
56
66
|
.spyOn(connectionManager, "buildRemoteCommand")
|
|
57
67
|
.mockRejectedValue(new Error("stop-before-spawn"));
|
|
58
|
-
const target: SSHConnectionTarget = { name: "
|
|
68
|
+
const target: SSHConnectionTarget = { name: "fishbox", host: "fishbox" };
|
|
59
69
|
|
|
60
70
|
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/stop-before-spawn/);
|
|
61
71
|
await expect(writeRemoteFile(target, "/tmp/x", new Uint8Array([1]), {})).rejects.toThrow(/stop-before-spawn/);
|
|
72
|
+
await expect(statRemotePath(target, "/etc/hosts")).rejects.toThrow(/stop-before-spawn/);
|
|
73
|
+
await expect(listRemoteDir(target, "/etc")).rejects.toThrow(/stop-before-spawn/);
|
|
62
74
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
75
|
+
// Each dispatch must start with `bash -c '…'` and embed the original
|
|
76
|
+
// POSIX snippet inside the quoted command. Read also drops `-n`
|
|
77
|
+
// (allowStdin: true) because cat-staging needs stdin streaming.
|
|
78
|
+
const dispatches = buildSpy.mock.calls.map(call => call[1] as string);
|
|
79
|
+
expect(dispatches[0]).toMatch(/^bash -c '.*head -c 1025/);
|
|
80
|
+
expect(dispatches[1]).toMatch(/^bash -c '.*cat > /);
|
|
66
81
|
expect(buildSpy.mock.calls[1]?.[2]).toMatchObject({ allowStdin: true });
|
|
82
|
+
expect(dispatches[2]).toMatch(/^bash -c '.*if \[ -d /);
|
|
83
|
+
expect(dispatches[3]).toMatch(/^bash -c '.*LC_ALL=C ls -1Ap /);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("uses sh -c when transferShell is sh (the most universal POSIX fallback)", async () => {
|
|
87
|
+
// Belt-and-suspenders: the common happy path with a sh-family login
|
|
88
|
+
// shell still routes through `sh -c` to keep one dispatch shape.
|
|
89
|
+
vi.spyOn(connectionManager, "ensureConnection").mockResolvedValue(undefined);
|
|
90
|
+
vi.spyOn(connectionManager, "ensureHostInfo").mockResolvedValue({
|
|
91
|
+
version: 4,
|
|
92
|
+
os: "linux",
|
|
93
|
+
shell: "sh",
|
|
94
|
+
transferShell: "sh",
|
|
95
|
+
compatEnabled: false,
|
|
96
|
+
});
|
|
97
|
+
const buildSpy = vi
|
|
98
|
+
.spyOn(connectionManager, "buildRemoteCommand")
|
|
99
|
+
.mockRejectedValue(new Error("stop-before-spawn"));
|
|
100
|
+
const target: SSHConnectionTarget = { name: "shbox", host: "shbox" };
|
|
101
|
+
|
|
102
|
+
await expect(readRemoteFile(target, "/etc/hosts", { maxBytes: 1024 })).rejects.toThrow(/stop-before-spawn/);
|
|
103
|
+
expect(buildSpy.mock.calls[0]?.[1]).toMatch(/^sh -c '.*head -c 1025/);
|
|
67
104
|
});
|
|
68
105
|
});
|