@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.
Files changed (150) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +3624 -3568
  3. package/dist/types/advisor/__tests__/config.test.d.ts +1 -0
  4. package/dist/types/advisor/advise-tool.d.ts +8 -4
  5. package/dist/types/advisor/config.d.ts +88 -0
  6. package/dist/types/advisor/index.d.ts +1 -0
  7. package/dist/types/advisor/transcript-recorder.d.ts +13 -2
  8. package/dist/types/advisor/watchdog.d.ts +20 -0
  9. package/dist/types/collab/guest.d.ts +29 -0
  10. package/dist/types/config/settings-schema.d.ts +81 -0
  11. package/dist/types/debug/log-viewer.d.ts +1 -0
  12. package/dist/types/debug/raw-sse.d.ts +1 -0
  13. package/dist/types/edit/hashline/diff.d.ts +0 -11
  14. package/dist/types/extensibility/tool-event-input.d.ts +7 -0
  15. package/dist/types/extensibility/utils.d.ts +12 -0
  16. package/dist/types/mcp/transports/index.d.ts +1 -0
  17. package/dist/types/mcp/transports/sse.d.ts +20 -0
  18. package/dist/types/modes/components/advisor-config.d.ts +59 -0
  19. package/dist/types/modes/components/index.d.ts +1 -0
  20. package/dist/types/modes/components/model-selector.d.ts +9 -1
  21. package/dist/types/modes/components/settings-selector.d.ts +1 -0
  22. package/dist/types/modes/components/status-line/component.d.ts +30 -1
  23. package/dist/types/modes/components/status-line/types.d.ts +13 -1
  24. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  25. package/dist/types/modes/interactive-mode.d.ts +10 -4
  26. package/dist/types/modes/skill-command.d.ts +32 -0
  27. package/dist/types/modes/types.d.ts +7 -2
  28. package/dist/types/session/agent-session.d.ts +58 -10
  29. package/dist/types/session/indexed-session-storage.d.ts +7 -1
  30. package/dist/types/session/messages.d.ts +26 -0
  31. package/dist/types/session/messages.test.d.ts +1 -0
  32. package/dist/types/session/session-entries.d.ts +31 -3
  33. package/dist/types/session/session-history-format.d.ts +6 -0
  34. package/dist/types/session/session-loader.d.ts +9 -1
  35. package/dist/types/session/session-manager.d.ts +6 -4
  36. package/dist/types/session/session-storage.d.ts +11 -0
  37. package/dist/types/session/session-title-slot.d.ts +19 -0
  38. package/dist/types/ssh/connection-manager.d.ts +47 -0
  39. package/dist/types/ssh/utils.d.ts +16 -0
  40. package/dist/types/task/executor.d.ts +3 -16
  41. package/dist/types/task/render.d.ts +0 -5
  42. package/dist/types/task/renderer.d.ts +13 -0
  43. package/dist/types/task/types.d.ts +16 -0
  44. package/dist/types/task/yield-assembly.d.ts +28 -0
  45. package/dist/types/tiny/text.d.ts +8 -0
  46. package/dist/types/tools/render-utils.d.ts +2 -0
  47. package/dist/types/tools/review.d.ts +6 -4
  48. package/dist/types/tools/ssh.d.ts +1 -1
  49. package/dist/types/tools/todo.d.ts +6 -0
  50. package/dist/types/tools/yield.d.ts +8 -3
  51. package/dist/types/utils/thinking-display.d.ts +4 -0
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +242 -10
  54. package/src/advisor/__tests__/config.test.ts +173 -0
  55. package/src/advisor/advise-tool.ts +11 -6
  56. package/src/advisor/config.ts +256 -0
  57. package/src/advisor/index.ts +1 -0
  58. package/src/advisor/runtime.ts +12 -2
  59. package/src/advisor/transcript-recorder.ts +25 -2
  60. package/src/advisor/watchdog.ts +57 -31
  61. package/src/autoresearch/index.ts +7 -2
  62. package/src/cli/gc-cli.ts +17 -10
  63. package/src/collab/guest.ts +43 -7
  64. package/src/config/model-registry.ts +80 -18
  65. package/src/config/settings-schema.ts +77 -0
  66. package/src/debug/index.ts +32 -7
  67. package/src/debug/log-viewer.ts +111 -53
  68. package/src/debug/raw-sse.ts +68 -48
  69. package/src/discovery/codex.ts +13 -5
  70. package/src/edit/hashline/diff.ts +57 -4
  71. package/src/eval/js/shared/local-module-loader.ts +23 -1
  72. package/src/export/html/template.js +13 -7
  73. package/src/extensibility/extensions/loader.ts +5 -3
  74. package/src/extensibility/extensions/wrapper.ts +9 -3
  75. package/src/extensibility/hooks/loader.ts +3 -3
  76. package/src/extensibility/hooks/tool-wrapper.ts +13 -4
  77. package/src/extensibility/plugins/manager.ts +2 -1
  78. package/src/extensibility/tool-event-input.ts +23 -0
  79. package/src/extensibility/utils.ts +74 -0
  80. package/src/internal-urls/docs-index.generated.txt +1 -1
  81. package/src/mcp/client.ts +3 -1
  82. package/src/mcp/manager.ts +12 -5
  83. package/src/mcp/transports/index.ts +1 -0
  84. package/src/mcp/transports/sse.ts +377 -0
  85. package/src/memories/index.ts +15 -6
  86. package/src/modes/components/advisor-config.ts +555 -0
  87. package/src/modes/components/advisor-message.ts +9 -2
  88. package/src/modes/components/agent-hub.ts +9 -4
  89. package/src/modes/components/index.ts +2 -0
  90. package/src/modes/components/model-selector.ts +79 -48
  91. package/src/modes/components/settings-selector.ts +1 -0
  92. package/src/modes/components/status-line/component.ts +144 -5
  93. package/src/modes/components/status-line/segments.ts +46 -21
  94. package/src/modes/components/status-line/types.ts +13 -1
  95. package/src/modes/components/tool-execution.ts +47 -6
  96. package/src/modes/controllers/command-controller.ts +23 -2
  97. package/src/modes/controllers/event-controller.ts +106 -0
  98. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  99. package/src/modes/controllers/input-controller.ts +61 -61
  100. package/src/modes/controllers/selector-controller.ts +100 -9
  101. package/src/modes/interactive-mode.ts +65 -5
  102. package/src/modes/skill-command.ts +116 -0
  103. package/src/modes/types.ts +7 -2
  104. package/src/modes/utils/ui-helpers.ts +41 -23
  105. package/src/prompts/agents/reviewer.md +11 -10
  106. package/src/prompts/review-custom-request.md +1 -2
  107. package/src/prompts/review-request.md +1 -2
  108. package/src/prompts/system/interrupted-thinking.md +7 -0
  109. package/src/prompts/system/recap-user.md +9 -0
  110. package/src/prompts/system/subagent-system-prompt.md +8 -5
  111. package/src/prompts/system/subagent-yield-reminder.md +6 -5
  112. package/src/prompts/system/system-prompt.md +0 -1
  113. package/src/prompts/tools/irc.md +2 -2
  114. package/src/prompts/tools/read.md +2 -2
  115. package/src/sdk.ts +28 -24
  116. package/src/session/agent-session.ts +867 -428
  117. package/src/session/indexed-session-storage.ts +86 -13
  118. package/src/session/messages.test.ts +125 -0
  119. package/src/session/messages.ts +172 -9
  120. package/src/session/redis-session-storage.ts +49 -2
  121. package/src/session/session-entries.ts +39 -2
  122. package/src/session/session-history-format.ts +29 -2
  123. package/src/session/session-listing.ts +54 -24
  124. package/src/session/session-loader.ts +66 -3
  125. package/src/session/session-manager.ts +113 -19
  126. package/src/session/session-persistence.ts +95 -1
  127. package/src/session/session-storage.ts +36 -0
  128. package/src/session/session-title-slot.ts +141 -0
  129. package/src/session/sql-session-storage.ts +71 -11
  130. package/src/slash-commands/builtin-registry.ts +16 -3
  131. package/src/ssh/__tests__/connection-manager-args.test.ts +123 -1
  132. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +55 -18
  133. package/src/ssh/connection-manager.ts +139 -12
  134. package/src/ssh/file-transfer.ts +23 -18
  135. package/src/ssh/ssh-executor.ts +2 -13
  136. package/src/ssh/utils.ts +19 -0
  137. package/src/task/executor.ts +21 -23
  138. package/src/task/render.ts +162 -20
  139. package/src/task/renderer.ts +14 -0
  140. package/src/task/types.ts +17 -0
  141. package/src/task/yield-assembly.ts +207 -0
  142. package/src/tiny/text.ts +23 -0
  143. package/src/tools/ask.ts +55 -4
  144. package/src/tools/render-utils.ts +2 -0
  145. package/src/tools/renderers.ts +8 -2
  146. package/src/tools/review.ts +17 -7
  147. package/src/tools/ssh.ts +8 -4
  148. package/src/tools/todo.ts +17 -1
  149. package/src/tools/yield.ts +140 -31
  150. 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, [path, content, mtimeMs]);
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
- return usage("Usage: /advisor [on|off|status|dump [raw]]", runtime);
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
- runtime.ctx.showStatus("Usage: /advisor [on|off|status|dump [raw]]");
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 { buildRemoteCommand, getHostInfo, type SSHConnectionTarget, type SSHHostShell } from "../connection-manager";
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
+ });