@oh-my-pi/pi-coding-agent 16.2.2 → 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 +49 -0
- package/dist/cli.js +3624 -3568
- 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/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/settings-schema.d.ts +81 -0
- package/dist/types/debug/log-viewer.d.ts +1 -0
- package/dist/types/debug/raw-sse.d.ts +1 -0
- package/dist/types/edit/hashline/diff.d.ts +0 -11
- package/dist/types/extensibility/tool-event-input.d.ts +7 -0
- package/dist/types/extensibility/utils.d.ts +12 -0
- 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 -1
- 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/session/agent-session.d.ts +58 -10
- package/dist/types/session/indexed-session-storage.d.ts +7 -1
- package/dist/types/session/messages.d.ts +26 -0
- 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/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 +242 -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 +12 -2
- package/src/advisor/transcript-recorder.ts +25 -2
- package/src/advisor/watchdog.ts +57 -31
- 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/settings-schema.ts +77 -0
- 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/edit/hashline/diff.ts +57 -4
- package/src/eval/js/shared/local-module-loader.ts +23 -1
- package/src/export/html/template.js +13 -7
- 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/manager.ts +2 -1
- 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/transports/index.ts +1 -0
- package/src/mcp/transports/sse.ts +377 -0
- package/src/memories/index.ts +15 -6
- 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/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 +144 -5
- package/src/modes/components/status-line/segments.ts +46 -21
- 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 +106 -0
- 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 -5
- package/src/modes/skill-command.ts +116 -0
- package/src/modes/types.ts +7 -2
- package/src/modes/utils/ui-helpers.ts +41 -23
- 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 +28 -24
- package/src/session/agent-session.ts +867 -428
- package/src/session/indexed-session-storage.ts +86 -13
- package/src/session/messages.test.ts +125 -0
- package/src/session/messages.ts +172 -9
- 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 +95 -1
- package/src/session/session-storage.ts +36 -0
- package/src/session/session-title-slot.ts +141 -0
- package/src/session/sql-session-storage.ts +71 -11
- package/src/slash-commands/builtin-registry.ts +16 -3
- 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/text.ts +23 -0
- package/src/tools/ask.ts +55 -4
- 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/yield.ts +140 -31
- package/src/utils/thinking-display.ts +15 -0
|
@@ -141,6 +141,100 @@ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string
|
|
|
141
141
|
return obj;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Read the duplication-relevant fields of an OpenAI Responses reasoning item.
|
|
146
|
+
* Returns `undefined` for anything that is not a `type: "reasoning"` object, so
|
|
147
|
+
* non-reasoning payload entries and corrupt signatures are never matched.
|
|
148
|
+
*/
|
|
149
|
+
function readReasoningItem(item: unknown): { encrypted_content?: string; id?: string } | undefined {
|
|
150
|
+
if (item === null || typeof item !== "object") return undefined;
|
|
151
|
+
if (!("type" in item) || item.type !== "reasoning") return undefined;
|
|
152
|
+
const reasoning: { encrypted_content?: string; id?: string } = {};
|
|
153
|
+
if ("encrypted_content" in item && typeof item.encrypted_content === "string" && item.encrypted_content.length > 0) {
|
|
154
|
+
reasoning.encrypted_content = item.encrypted_content;
|
|
155
|
+
}
|
|
156
|
+
if ("id" in item && typeof item.id === "string" && item.id.length > 0) reasoning.id = item.id;
|
|
157
|
+
return reasoning;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* True when a `thinkingSignature` (a JSON-encoded reasoning item) is already
|
|
162
|
+
* carried by a reasoning item in the message's provider payload — matched on
|
|
163
|
+
* `encrypted_content` (the load-bearing blob) when present, else on item `id`.
|
|
164
|
+
* A signature the payload does not cover is never reported as recoverable, so it
|
|
165
|
+
* is always kept.
|
|
166
|
+
*/
|
|
167
|
+
function signatureCoveredByPayload(
|
|
168
|
+
signature: string,
|
|
169
|
+
encrypted: ReadonlySet<string>,
|
|
170
|
+
ids: ReadonlySet<string>,
|
|
171
|
+
): boolean {
|
|
172
|
+
let parsed: unknown;
|
|
173
|
+
try {
|
|
174
|
+
parsed = JSON.parse(signature);
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
const reasoning = readReasoningItem(parsed);
|
|
179
|
+
if (!reasoning) return false;
|
|
180
|
+
if (reasoning.encrypted_content) return encrypted.has(reasoning.encrypted_content);
|
|
181
|
+
if (reasoning.id) return ids.has(reasoning.id);
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Drop `thinkingSignature` from assistant thinking blocks whose reasoning item is
|
|
187
|
+
* already carried, verbatim, in the message's OpenAI Responses `providerPayload`.
|
|
188
|
+
*
|
|
189
|
+
* Responses/Codex turns mint each reasoning item once and store it twice:
|
|
190
|
+
* `providerPayload.items` (the authoritative native-history copy that replay and
|
|
191
|
+
* remote compaction read) and `content[].thinkingSignature`, which is literally
|
|
192
|
+
* `JSON.stringify(reasoningItem)` — including the large `encrypted_content` blob.
|
|
193
|
+
* Replay only ever reads the payload; the signature is a no-payload fallback that
|
|
194
|
+
* same-provider turns never reach and cross-model turns strip as untrustworthy.
|
|
195
|
+
* Persisting both stores the encrypted reasoning twice for zero token or replay
|
|
196
|
+
* benefit, so the on-disk copy drops the duplicate signature whenever its
|
|
197
|
+
* reasoning item is recoverable from the payload. The in-memory entry is left
|
|
198
|
+
* untouched; only the serialized line is slimmed.
|
|
199
|
+
*/
|
|
200
|
+
function stripReplayedReasoningSignatures(entry: FileEntry): FileEntry {
|
|
201
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") return entry;
|
|
202
|
+
const message = entry.message;
|
|
203
|
+
const payload = message.providerPayload;
|
|
204
|
+
if (payload?.type !== "openaiResponsesHistory" || !Array.isArray(payload.items)) return entry;
|
|
205
|
+
const hasSignedThinking = message.content.some(
|
|
206
|
+
block =>
|
|
207
|
+
block.type === "thinking" && typeof block.thinkingSignature === "string" && block.thinkingSignature.length > 0,
|
|
208
|
+
);
|
|
209
|
+
if (!hasSignedThinking) return entry;
|
|
210
|
+
|
|
211
|
+
const encrypted = new Set<string>();
|
|
212
|
+
const ids = new Set<string>();
|
|
213
|
+
for (const rawItem of payload.items) {
|
|
214
|
+
const reasoning = readReasoningItem(rawItem);
|
|
215
|
+
if (!reasoning) continue;
|
|
216
|
+
if (reasoning.encrypted_content) encrypted.add(reasoning.encrypted_content);
|
|
217
|
+
if (reasoning.id) ids.add(reasoning.id);
|
|
218
|
+
}
|
|
219
|
+
if (encrypted.size === 0 && ids.size === 0) return entry;
|
|
220
|
+
|
|
221
|
+
let changed = false;
|
|
222
|
+
const content = message.content.map(block => {
|
|
223
|
+
if (
|
|
224
|
+
block.type !== "thinking" ||
|
|
225
|
+
typeof block.thinkingSignature !== "string" ||
|
|
226
|
+
block.thinkingSignature.length === 0
|
|
227
|
+
) {
|
|
228
|
+
return block;
|
|
229
|
+
}
|
|
230
|
+
if (!signatureCoveredByPayload(block.thinkingSignature, encrypted, ids)) return block;
|
|
231
|
+
changed = true;
|
|
232
|
+
return { ...block, thinkingSignature: undefined };
|
|
233
|
+
});
|
|
234
|
+
if (!changed) return entry;
|
|
235
|
+
return { ...entry, message: { ...message, content } };
|
|
236
|
+
}
|
|
237
|
+
|
|
144
238
|
export function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): FileEntry {
|
|
145
|
-
return truncateForPersistence(entry, blobStore) as FileEntry;
|
|
239
|
+
return truncateForPersistence(stripReplayedReasoningSignatures(entry), blobStore) as FileEntry;
|
|
146
240
|
}
|
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as fsp from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { hasFsCode, isEnoent, logger, peekFileEnds, Snowflake, toError } from "@oh-my-pi/pi-utils";
|
|
5
|
+
import { overlayTitleSlotContent, type SessionTitleUpdate, serializeTitleSlot } from "./session-title-slot";
|
|
5
6
|
|
|
6
7
|
const utf8Decoder = new TextDecoder("utf-8");
|
|
7
8
|
|
|
@@ -31,6 +32,14 @@ export interface SessionStorage {
|
|
|
31
32
|
ensureDirSync(dir: string): void;
|
|
32
33
|
existsSync(path: string): boolean;
|
|
33
34
|
writeTextSync(path: string, content: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Update the current session title through the storage backend.
|
|
37
|
+
*
|
|
38
|
+
* File-like backends rewrite the fixed-width JSONL title slot; indexed
|
|
39
|
+
* backends can store the semantic title fields and synthesize the slot when
|
|
40
|
+
* reading.
|
|
41
|
+
*/
|
|
42
|
+
updateSessionTitle(path: string, update: SessionTitleUpdate): Promise<void>;
|
|
34
43
|
statSync(path: string): SessionStorageStat;
|
|
35
44
|
listFilesSync(dir: string, pattern: string): string[];
|
|
36
45
|
|
|
@@ -163,6 +172,25 @@ export class FileSessionStorage implements SessionStorage {
|
|
|
163
172
|
}
|
|
164
173
|
}
|
|
165
174
|
|
|
175
|
+
async updateSessionTitle(fpath: string, update: SessionTitleUpdate): Promise<void> {
|
|
176
|
+
const fd = fs.openSync(fpath, "r+");
|
|
177
|
+
try {
|
|
178
|
+
const buf = Buffer.from(serializeTitleSlot(update), "utf-8");
|
|
179
|
+
let offset = 0;
|
|
180
|
+
while (offset < buf.length) {
|
|
181
|
+
const written = fs.writeSync(fd, buf, offset, buf.length - offset, offset);
|
|
182
|
+
if (written === 0) {
|
|
183
|
+
throw new Error("Short write");
|
|
184
|
+
}
|
|
185
|
+
offset += written;
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
throw toError(err);
|
|
189
|
+
} finally {
|
|
190
|
+
fs.closeSync(fd);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
166
194
|
statSync(path: string): SessionStorageStat {
|
|
167
195
|
const stats = fs.statSync(path);
|
|
168
196
|
return { size: stats.size, mtimeMs: stats.mtimeMs, mtime: stats.mtime };
|
|
@@ -526,6 +554,14 @@ export class MemorySessionStorage implements SessionStorage {
|
|
|
526
554
|
this.#files.set(path, createMemoryFileEntry(content, Date.now()));
|
|
527
555
|
}
|
|
528
556
|
|
|
557
|
+
async updateSessionTitle(path: string, update: SessionTitleUpdate): Promise<void> {
|
|
558
|
+
const entry = this.#requireEntry(path);
|
|
559
|
+
this.#files.set(
|
|
560
|
+
path,
|
|
561
|
+
createMemoryFileEntry(overlayTitleSlotContent(materializeMemoryEntry(entry), update), Date.now()),
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
|
|
529
565
|
/**
|
|
530
566
|
* Internal O(1) append used by {@link MemorySessionStorageWriter}. Lazily
|
|
531
567
|
* creates the entry. External callers should go through `openWriter()`
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESSION_TITLE_SLOT_BYTES,
|
|
3
|
+
SESSION_TITLE_SLOT_ENTRY_TYPE,
|
|
4
|
+
type SessionTitleSlotEntry,
|
|
5
|
+
type SessionTitleSource,
|
|
6
|
+
} from "./session-entries";
|
|
7
|
+
|
|
8
|
+
const utf8Encoder = new TextEncoder();
|
|
9
|
+
|
|
10
|
+
/** Semantic title update persisted by session storage backends. */
|
|
11
|
+
export interface SessionTitleUpdate {
|
|
12
|
+
title?: string;
|
|
13
|
+
source?: SessionTitleSource;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function byteLength(value: string): number {
|
|
18
|
+
return utf8Encoder.encode(value).byteLength;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function titleSlotLine(title: string, source: SessionTitleSource | undefined, updatedAt: string, pad: string): string {
|
|
22
|
+
const slot: SessionTitleSlotEntry = source
|
|
23
|
+
? {
|
|
24
|
+
type: SESSION_TITLE_SLOT_ENTRY_TYPE,
|
|
25
|
+
v: 1,
|
|
26
|
+
title,
|
|
27
|
+
source,
|
|
28
|
+
updatedAt,
|
|
29
|
+
pad,
|
|
30
|
+
}
|
|
31
|
+
: {
|
|
32
|
+
type: SESSION_TITLE_SLOT_ENTRY_TYPE,
|
|
33
|
+
v: 1,
|
|
34
|
+
title,
|
|
35
|
+
updatedAt,
|
|
36
|
+
pad,
|
|
37
|
+
};
|
|
38
|
+
return `${JSON.stringify(slot)}\n`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function truncateTitleForSlot(title: string, source: SessionTitleSource | undefined, updatedAt: string): string {
|
|
42
|
+
const codePoints = [...title];
|
|
43
|
+
let low = 0;
|
|
44
|
+
let high = codePoints.length;
|
|
45
|
+
let best = "";
|
|
46
|
+
|
|
47
|
+
while (low <= high) {
|
|
48
|
+
const mid = (low + high) >>> 1;
|
|
49
|
+
const candidate = codePoints.slice(0, mid).join("");
|
|
50
|
+
if (byteLength(titleSlotLine(candidate, source, updatedAt, "")) <= SESSION_TITLE_SLOT_BYTES) {
|
|
51
|
+
best = candidate;
|
|
52
|
+
low = mid + 1;
|
|
53
|
+
} else {
|
|
54
|
+
high = mid - 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return best;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isSessionTitleSource(value: unknown): value is SessionTitleSource {
|
|
62
|
+
return value === "auto" || value === "user";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseTitleSlotObject(value: unknown): SessionTitleSlotEntry | undefined {
|
|
66
|
+
if (typeof value !== "object" || value === null) return undefined;
|
|
67
|
+
const record = value as Record<string, unknown>;
|
|
68
|
+
if (record.type !== SESSION_TITLE_SLOT_ENTRY_TYPE || record.v !== 1) return undefined;
|
|
69
|
+
if (typeof record.title !== "string" || typeof record.updatedAt !== "string" || typeof record.pad !== "string") {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
const source = record.source;
|
|
73
|
+
if (source !== undefined && !isSessionTitleSource(source)) return undefined;
|
|
74
|
+
const slot: SessionTitleSlotEntry = {
|
|
75
|
+
type: SESSION_TITLE_SLOT_ENTRY_TYPE,
|
|
76
|
+
v: 1,
|
|
77
|
+
title: record.title,
|
|
78
|
+
updatedAt: record.updatedAt,
|
|
79
|
+
pad: record.pad,
|
|
80
|
+
};
|
|
81
|
+
if (source) slot.source = source;
|
|
82
|
+
return slot;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse a physical title slot JSONL line. Returns undefined for legacy headers. */
|
|
86
|
+
export function parseTitleSlotLine(line: string): SessionTitleSlotEntry | undefined {
|
|
87
|
+
try {
|
|
88
|
+
return parseTitleSlotObject(JSON.parse(line)) ?? undefined;
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parse the fixed-width title slot from a physical session body. */
|
|
95
|
+
export function parseTitleSlotFromContent(content: string): SessionTitleSlotEntry | undefined {
|
|
96
|
+
const newlineIndex = content.indexOf("\n");
|
|
97
|
+
if (newlineIndex < 0) return undefined;
|
|
98
|
+
return parseTitleSlotLine(content.slice(0, newlineIndex));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Convert a parsed title slot to the semantic storage update shape. */
|
|
102
|
+
export function titleUpdateFromSlot(slot: SessionTitleSlotEntry | undefined): SessionTitleUpdate | undefined {
|
|
103
|
+
if (!slot) return undefined;
|
|
104
|
+
return {
|
|
105
|
+
title: slot.title,
|
|
106
|
+
source: slot.source,
|
|
107
|
+
updatedAt: slot.updatedAt,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Serialize the fixed-width first-line title slot, exactly 256 UTF-8 bytes including newline. */
|
|
112
|
+
export function serializeTitleSlot(options: SessionTitleUpdate): string {
|
|
113
|
+
const title = truncateTitleForSlot(options.title ?? "", options.source, options.updatedAt);
|
|
114
|
+
const unpadded = titleSlotLine(title, options.source, options.updatedAt, "");
|
|
115
|
+
const padBytes = SESSION_TITLE_SLOT_BYTES - byteLength(unpadded);
|
|
116
|
+
if (padBytes < 0) throw new Error("Session title slot metadata exceeds fixed slot size");
|
|
117
|
+
const line = titleSlotLine(title, options.source, options.updatedAt, " ".repeat(padBytes));
|
|
118
|
+
if (byteLength(line) !== SESSION_TITLE_SLOT_BYTES) {
|
|
119
|
+
throw new Error("Session title slot serialization failed to produce fixed-width output");
|
|
120
|
+
}
|
|
121
|
+
return line;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Replace the physical fixed-width title slot in a full session body. */
|
|
125
|
+
export function overlayTitleSlotContent(content: string, update: SessionTitleUpdate): string {
|
|
126
|
+
const slot = Buffer.from(serializeTitleSlot(update), "utf-8");
|
|
127
|
+
const existing = Buffer.from(content, "utf-8");
|
|
128
|
+
if (existing.length <= slot.length) return slot.toString("utf-8");
|
|
129
|
+
return Buffer.concat([slot, existing.subarray(slot.length)]).toString("utf-8");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Replace the physical fixed-width title slot in a prefix byte window. */
|
|
133
|
+
export function overlayTitleSlotPrefix(prefix: string, prefixBytes: number, update: SessionTitleUpdate): string {
|
|
134
|
+
if (prefixBytes <= 0) return "";
|
|
135
|
+
const slot = Buffer.from(serializeTitleSlot(update), "utf-8");
|
|
136
|
+
if (prefixBytes <= slot.length) return slot.subarray(0, prefixBytes).toString("utf-8");
|
|
137
|
+
const existing = Buffer.from(prefix, "utf-8");
|
|
138
|
+
return Buffer.concat([slot, existing.subarray(slot.length)])
|
|
139
|
+
.subarray(0, prefixBytes)
|
|
140
|
+
.toString("utf-8");
|
|
141
|
+
}
|
|
@@ -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> {
|
|
@@ -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
|
},
|
|
@@ -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
|
+
});
|