mixdog 0.9.14 → 0.9.16

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 (125) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench/cache-probe-tasks.json +1 -1
  3. package/scripts/bench/lead-review-tasks-r3.json +1 -1
  4. package/scripts/bench/r4-mixed-tasks.json +1 -1
  5. package/scripts/bench/review-tasks.json +1 -1
  6. package/scripts/build-runtime-windows.ps1 +242 -242
  7. package/scripts/explore-bench.mjs +5 -4
  8. package/scripts/ingest-pure-conversation-smoke.mjs +27 -0
  9. package/scripts/output-style-smoke.mjs +3 -3
  10. package/scripts/recall-usecase-cases.json +1 -1
  11. package/scripts/smoke-runtime-negative.ps1 +106 -106
  12. package/scripts/termio-input-smoke.mjs +199 -0
  13. package/scripts/tool-efficiency-diag.mjs +88 -0
  14. package/scripts/tool-smoke.mjs +40 -24
  15. package/scripts/tui-frame-harness-shim.mjs +32 -0
  16. package/scripts/tui-frame-harness.mjs +306 -0
  17. package/src/agents/heavy-worker/AGENT.md +6 -7
  18. package/src/agents/worker/AGENT.md +6 -7
  19. package/src/lib/keychain-cjs.cjs +28 -11
  20. package/src/lib/rules-builder.cjs +6 -10
  21. package/src/mixdog-session-runtime.mjs +90 -20
  22. package/src/output-styles/simple.md +22 -24
  23. package/src/rules/agent/00-core.md +12 -11
  24. package/src/rules/agent/30-explorer.md +22 -21
  25. package/src/rules/lead/01-general.md +8 -8
  26. package/src/rules/lead/02-channels.md +3 -3
  27. package/src/rules/lead/lead-brief.md +11 -12
  28. package/src/rules/shared/01-tool.md +25 -14
  29. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -3
  30. package/src/runtime/agent/orchestrator/config.mjs +1 -1
  31. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -1
  32. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +21 -1
  33. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +29 -8
  34. package/src/runtime/agent/orchestrator/providers/gemini.mjs +13 -5
  35. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  36. package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +11 -0
  37. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +18 -0
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +1 -1
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +30 -2
  40. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +19 -0
  41. package/src/runtime/agent/orchestrator/session/compact/constants.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +1 -1
  43. package/src/runtime/agent/orchestrator/session/compact/summary.mjs +37 -15
  44. package/src/runtime/agent/orchestrator/session/context-utils.mjs +55 -0
  45. package/src/runtime/agent/orchestrator/session/lifecycle-scan.mjs +177 -0
  46. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +12 -19
  47. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +10 -0
  48. package/src/runtime/agent/orchestrator/session/loop.mjs +22 -1
  49. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +8 -0
  50. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +13 -18
  51. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +5 -1
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -1
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +59 -2
  54. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +18 -0
  55. package/src/runtime/agent/orchestrator/session/store.mjs +33 -25
  56. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
  57. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +7 -5
  58. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +38 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +24 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +2 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +14 -1
  62. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +25 -1
  63. package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +73 -3
  64. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +23 -10
  65. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +55 -0
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +21 -13
  67. package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +13 -2
  68. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +44 -6
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +5 -0
  70. package/src/runtime/channels/backends/telegram.mjs +29 -9
  71. package/src/runtime/channels/lib/event-queue.mjs +6 -2
  72. package/src/runtime/channels/lib/memory-client.mjs +66 -1
  73. package/src/runtime/channels/lib/webhook/deliveries.mjs +22 -1
  74. package/src/runtime/memory/index.mjs +95 -8
  75. package/src/runtime/memory/lib/cycle-scheduler.mjs +24 -5
  76. package/src/runtime/memory/lib/memory-cycle1.mjs +45 -3
  77. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +7 -3
  78. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +54 -10
  79. package/src/runtime/memory/lib/memory-cycle2.mjs +21 -8
  80. package/src/runtime/memory/lib/memory-embed.mjs +48 -0
  81. package/src/runtime/memory/lib/memory-recall-store.mjs +57 -13
  82. package/src/runtime/memory/lib/memory.mjs +88 -1
  83. package/src/runtime/memory/lib/session-ingest.mjs +34 -3
  84. package/src/runtime/memory/lib/trace-store.mjs +52 -3
  85. package/src/runtime/memory/lib/transcript-ingest.mjs +27 -4
  86. package/src/runtime/shared/child-guardian.mjs +4 -2
  87. package/src/runtime/shared/open-url.mjs +2 -1
  88. package/src/runtime/shared/singleton-owner.mjs +26 -0
  89. package/src/runtime/shared/tool-execution-contract.mjs +25 -0
  90. package/src/runtime/shared/transcript-writer.mjs +3 -1
  91. package/src/session-runtime/config-helpers.mjs +7 -2
  92. package/src/session-runtime/cwd-plugins.mjs +6 -1
  93. package/src/session-runtime/effort.mjs +6 -1
  94. package/src/session-runtime/plugin-mcp.mjs +116 -12
  95. package/src/session-runtime/settings-api.mjs +7 -1
  96. package/src/standalone/channel-worker.mjs +25 -3
  97. package/src/standalone/explore-tool.mjs +5 -5
  98. package/src/standalone/hook-bus/config.mjs +38 -2
  99. package/src/standalone/hook-bus/handlers.mjs +103 -11
  100. package/src/standalone/hook-bus.mjs +20 -6
  101. package/src/standalone/memory-runtime-proxy.mjs +40 -5
  102. package/src/tui/App.jsx +214 -30
  103. package/src/tui/app/core-memory-picker.mjs +6 -6
  104. package/src/tui/app/model-options.mjs +10 -4
  105. package/src/tui/app/settings-picker.mjs +5 -30
  106. package/src/tui/app/slash-commands.mjs +0 -1
  107. package/src/tui/app/slash-dispatch.mjs +0 -16
  108. package/src/tui/app/text-layout.mjs +57 -0
  109. package/src/tui/app/transcript-window.mjs +53 -2
  110. package/src/tui/app/use-mouse-input.mjs +60 -47
  111. package/src/tui/app/use-transcript-window.mjs +38 -10
  112. package/src/tui/components/PromptInput.jsx +18 -1
  113. package/src/tui/components/TextEntryPanel.jsx +97 -33
  114. package/src/tui/dist/index.mjs +567 -229
  115. package/src/tui/engine/tool-card-results.mjs +22 -5
  116. package/src/tui/engine.mjs +126 -7
  117. package/src/tui/index.jsx +4 -1
  118. package/src/workflows/default/WORKFLOW.md +27 -31
  119. package/vendor/ink/build/components/App.js +62 -17
  120. package/vendor/ink/build/ink.js +78 -3
  121. package/vendor/ink/build/input-parser.d.ts +9 -4
  122. package/vendor/ink/build/input-parser.js +45 -176
  123. package/vendor/ink/build/log-update.js +47 -2
  124. package/vendor/ink/build/termio-keypress.js +240 -0
  125. package/vendor/ink/build/termio-tokenize.js +253 -0
@@ -0,0 +1,32 @@
1
+ // Mirror of vendor/ink/build/ink.js shouldClearTerminalForFrame, with the
2
+ // Windows branch parameterized so the harness can probe both platforms without
3
+ // relying on process.platform. Kept byte-faithful to the source predicate so
4
+ // the harness reflects real clear decisions.
5
+ export function shouldClearTerminalForFrameProbe({
6
+ isTty, viewportRows, previousViewportRows, previousOutputHeight,
7
+ nextOutputHeight, isUnmounting, isWindows,
8
+ }) {
9
+ if (!isTty) return false;
10
+ const priorViewportRows = previousViewportRows ?? viewportRows;
11
+ const hadPreviousFrame = previousOutputHeight > 0;
12
+ const wasFullscreen = previousOutputHeight >= priorViewportRows;
13
+ const wasOverflowing = previousOutputHeight > priorViewportRows;
14
+ const isOverflowing = nextOutputHeight > viewportRows;
15
+ const isFullscreen = nextOutputHeight >= viewportRows;
16
+ const isLeavingFullscreen = wasFullscreen && nextOutputHeight < viewportRows;
17
+ const isShrinkingAtViewport = hadPreviousFrame &&
18
+ nextOutputHeight < previousOutputHeight &&
19
+ (wasFullscreen || isFullscreen || wasOverflowing || isOverflowing);
20
+ const shouldClearOnUnmount = isUnmounting && wasFullscreen;
21
+ const viewportResized = previousViewportRows != null && previousViewportRows !== viewportRows;
22
+ if (isWindows && (wasFullscreen || isFullscreen) &&
23
+ (viewportResized || isShrinkingAtViewport || isLeavingFullscreen)) {
24
+ return true;
25
+ }
26
+ return (
27
+ wasOverflowing ||
28
+ (isOverflowing && hadPreviousFrame) ||
29
+ isLeavingFullscreen ||
30
+ isShrinkingAtViewport ||
31
+ shouldClearOnUnmount);
32
+ }
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/tui-frame-harness.mjs — renderer-level frame-grid evidence harness.
4
+ *
5
+ * Drives vendor/ink log-update createIncremental(fakeStream) through the SAME
6
+ * wrapper logic ink.js renderInteractiveFrame() applies (fullscreen detect +
7
+ * trailing-newline normalization + shouldClearTerminalForFrame), replays the
8
+ * emitted ANSI into a minimal VT grid interpreter, and asserts the absolute row
9
+ * of a tracked marker line (the "prompt" / "statusline") across each frame.
10
+ *
11
+ * A deviant frame = the marker's absolute row changes for a single frame then
12
+ * snaps back. That is the one-row-low dip reported under Windows Terminal.
13
+ *
14
+ * Run: node scripts/tui-frame-harness.mjs
15
+ */
16
+ // [harness] log-update reads process.platform/WT_SESSION AT IMPORT to pick its
17
+ // Windows-safe absolute-cursor branch. Force WT_SESSION on BEFORE importing it
18
+ // so POSIX/CI runs exercise the same branch WT users hit. Must precede the
19
+ // dynamic import below.
20
+ process.env.WT_SESSION = process.env.WT_SESSION || '1';
21
+ const { default: logUpdate } = await import('../vendor/ink/build/log-update.js');
22
+ const { shouldClearTerminalForFrameProbe } = await import('./tui-frame-harness-shim.mjs');
23
+ const { default: ansiEscapes } = await import('ansi-escapes');
24
+
25
+ // Fail loudly if the Windows-safe branch is NOT engaged: without it the harness
26
+ // silently tests the wrong path and reports a false pass. Probe by driving a
27
+ // fullscreen→one-short pair through a throwaway log and asserting the branch's
28
+ // absolute cursorTo(0,y) addressing (not a relative cursorUp walk) is emitted.
29
+ function assertWindowsBranchEngaged() {
30
+ const chunks = [];
31
+ const fs = { write: (s) => { chunks.push(s); return true; }, isTTY: true, columns: 20, rows: 4 };
32
+ const log = logUpdate.create(fs, { incremental: true });
33
+ log.sync('a\nb\nc\nd'); // 4 lines, fullscreen, no trailing nl
34
+ chunks.length = 0;
35
+ log('a\nX\nc\nd'); // change one middle row, still fullscreen
36
+ const emitted = chunks.join('');
37
+ // Windows-safe branch uses absolute cursorTo(0, i) => CSI <row>;1H. The POSIX
38
+ // relative branch uses cursorUp / cursorNextLine (CSI A / E) with no ;1H rows.
39
+ const hasAbsolute = /\x1b\[\d+;1H/.test(emitted);
40
+ if (!hasAbsolute) {
41
+ console.error('tui-frame-harness: FAIL — log-update Windows-safe branch NOT engaged '
42
+ + '(WT_SESSION/platform did not force it at import). Emitted:', JSON.stringify(emitted));
43
+ process.exit(1);
44
+ }
45
+ }
46
+ assertWindowsBranchEngaged();
47
+
48
+ // ---- Minimal VT grid interpreter -----------------------------------------
49
+ // Supports the escapes log-update / ink emit: cursorTo(x[,y]), cursorUp/Down/
50
+ // NextLine, eraseLine, eraseEndLine, eraseLines(n), clearTerminal, plain text,
51
+ // newline, SGR (ignored for geometry), hide/show cursor (ignored).
52
+ class VT {
53
+ constructor(rows, cols) {
54
+ this.rows = rows;
55
+ this.cols = cols;
56
+ this.grid = Array.from({ length: rows }, () => '');
57
+ this.cx = 0;
58
+ this.cy = 0;
59
+ }
60
+ _clampRow() { if (this.cy < 0) this.cy = 0; if (this.cy >= this.rows) this.cy = this.rows - 1; }
61
+ write(data) {
62
+ let i = 0;
63
+ while (i < data.length) {
64
+ const ch = data[i];
65
+ if (ch === '\u001B') {
66
+ // CSI
67
+ if (data[i + 1] === '[') {
68
+ let j = i + 2;
69
+ let params = '';
70
+ while (j < data.length && /[0-9;]/.test(data[j])) { params += data[j]; j++; }
71
+ const cmd = data[j];
72
+ const nums = params.split(';').map((p) => (p === '' ? undefined : Number(p)));
73
+ this._csi(cmd, nums, params);
74
+ i = j + 1;
75
+ continue;
76
+ }
77
+ // other escapes (e.g. \u001B[?25l already handled via '[' path); skip 2
78
+ i += 1;
79
+ continue;
80
+ }
81
+ if (ch === '\n') { this.cy += 1; this.cx = 0; this._clampRow(); i++; continue; }
82
+ if (ch === '\r') { this.cx = 0; i++; continue; }
83
+ // printable
84
+ this._clampRow();
85
+ const row = this.grid[this.cy];
86
+ const padded = row.length < this.cx ? row + ' '.repeat(this.cx - row.length) : row;
87
+ this.grid[this.cy] = padded.slice(0, this.cx) + ch + padded.slice(this.cx + 1);
88
+ this.cx += 1;
89
+ i++;
90
+ }
91
+ }
92
+ _csi(cmd, nums, params) {
93
+ const n = nums[0];
94
+ switch (cmd) {
95
+ case 'H': case 'f': { // cursor position (1-based row;col)
96
+ this.cy = (nums[0] ?? 1) - 1; this.cx = (nums[1] ?? 1) - 1; this._clampRow(); break;
97
+ }
98
+ case 'A': this.cy -= (n ?? 1); this._clampRow(); break; // up
99
+ case 'B': this.cy += (n ?? 1); this._clampRow(); break; // down
100
+ case 'C': this.cx += (n ?? 1); break; // forward
101
+ case 'D': this.cx -= (n ?? 1); if (this.cx < 0) this.cx = 0; break; // back
102
+ case 'E': this.cy += (n ?? 1); this.cx = 0; this._clampRow(); break; // next line
103
+ case 'F': this.cy -= (n ?? 1); this.cx = 0; this._clampRow(); break; // prev line
104
+ case 'G': this.cx = (n ?? 1) - 1; break; // column (cursorTo(x) → \u001B[(x+1)G)
105
+ case 'J': { // erase display; 2 = whole screen (clearTerminal uses 2J + H)
106
+ if (n === 2 || n === 3) { this.grid = Array.from({ length: this.rows }, () => ''); }
107
+ break;
108
+ }
109
+ case 'K': { // erase line (eraseEndLine=0/none; eraseLine full = 2K)
110
+ if (n === undefined || n === 0) { this.grid[this.cy] = this.grid[this.cy].slice(0, this.cx); }
111
+ else if (n === 1) { this.grid[this.cy] = ' '.repeat(this.cx) + this.grid[this.cy].slice(this.cx); }
112
+ else if (n === 2) { this.grid[this.cy] = ''; }
113
+ break;
114
+ }
115
+ // SGR (m), hide/show cursor (h/l), etc. — no geometry effect
116
+ default: break;
117
+ }
118
+ }
119
+ markerRow(marker) {
120
+ for (let r = 0; r < this.rows; r++) {
121
+ if (this.grid[r].includes(marker)) return r;
122
+ }
123
+ return -1;
124
+ }
125
+ dump() {
126
+ return this.grid.map((r, i) => `${String(i).padStart(2)}|${r}`).join('\n');
127
+ }
128
+ }
129
+
130
+ // ---- Wrapper mirroring ink.js renderInteractiveFrame ----------------------
131
+ function makeDriver({ rows, cols, isWindows }) {
132
+ const chunks = [];
133
+ const fakeStream = { write: (s) => { chunks.push(s); return true; }, isTTY: true, columns: cols, rows };
134
+ // Force the incremental renderer's Windows branch by faking env/platform is
135
+ // out of scope here; log-update reads process.platform/WT_SESSION at import.
136
+ const log = logUpdate.create(fakeStream, { incremental: true });
137
+ const vt = new VT(rows, cols);
138
+ let lastOutput = '';
139
+ let lastOutputHeight = 0;
140
+ let lastViewportRows = rows;
141
+ let lastOneShortPadded = false;
142
+ const commit = (output) => {
143
+ chunks.length = 0;
144
+ // [FAITHFUL] outputHeight = output.get().height = Output.height = the
145
+ // Yoga-computed ROOT height (renderer.js L45). The App pins the outer
146
+ // column to height=resizeState.rows, so a steady frame reports height=rows.
147
+ // BUT when the App's row accounting is off by one for a single commit
148
+ // (a reclaimed panel/hint row not yet refilled), the root lays out at
149
+ // rows-1 and outputHeight==rows-1. Model the height as the caller states
150
+ // it via a marker: the frame string's real line count is authoritative
151
+ // here because we construct each frame to physically carry `heightRows`
152
+ // lines. So derive it from the string.
153
+ const lineCount = output.split('\n').length;
154
+ let outputHeight = output === '' ? 0 : lineCount;
155
+ // Mirror ink.js (post-fix): an exactly-one-row-short frame following a
156
+ // fullscreen frame is padded with a leading blank line so the bottom
157
+ // cluster stays at its steady rows and the fullscreen path stays engaged.
158
+ const wasFullscreenFrame = lastOutputHeight >= lastViewportRows && lastOutputHeight > 0;
159
+ // Mirror ink.js guard exactly: Windows-like only + one-commit transient.
160
+ const isExactlyOneRowShort = isWindows && outputHeight === rows - 1
161
+ && wasFullscreenFrame && !lastOneShortPadded;
162
+ if (isExactlyOneRowShort) {
163
+ output = '\n' + output;
164
+ outputHeight = rows;
165
+ lastOneShortPadded = true;
166
+ } else {
167
+ lastOneShortPadded = false;
168
+ }
169
+ const isFullscreen = outputHeight >= rows;
170
+ let outputToRender = isFullscreen ? output : output + '\n';
171
+ if (isFullscreen && outputToRender.endsWith('\n')) outputToRender += '\u001B[0m';
172
+ const clearDecision = shouldClearTerminalForFrameProbe({
173
+ isTty: true, viewportRows: rows, previousViewportRows: lastViewportRows,
174
+ previousOutputHeight: lastOutputHeight, nextOutputHeight: outputHeight,
175
+ isUnmounting: false, isWindows,
176
+ });
177
+ if (clearDecision) {
178
+ fakeStream.write('\u001B[0m' + ansiEscapes.clearTerminal + outputToRender);
179
+ log.sync(outputToRender);
180
+ } else {
181
+ log(outputToRender);
182
+ }
183
+ for (const c of chunks) vt.write(c);
184
+ lastOutput = output;
185
+ lastOutputHeight = outputHeight;
186
+ lastViewportRows = rows;
187
+ const trailingNL = outputToRender.endsWith('\n');
188
+ return { outputHeight, trailingNL, clearDecision, isFullscreen };
189
+ };
190
+ return { vt, commit };
191
+ }
192
+
193
+ // ---- Scenario builders ----------------------------------------------------
194
+ // Build an App-like frame with the prompt+statusline ALWAYS pinned to the two
195
+ // bottom-most non-blank rows (matching App.jsx: the bottom bar never moves).
196
+ // The App keeps total painted rows == viewport by ceding transcript rows to a
197
+ // panel; so the prompt row is invariant whether or not the palette is open.
198
+ // The only thing that changes between commits is: does the serialized output
199
+ // end on a blank row (→ trailing-newline flip, fs classification flip)?
200
+ // `bottomBlank` models a frame whose LAST painted row is blank padding.
201
+ // `shortByOne` = the root Yoga height is rows-1 for this commit (a reclaimed
202
+ // row that the App's accounting has NOT yet refilled). This is the documented
203
+ // deviant: outputHeight = rows-1 < viewportRows → ink.js takes output+'\n'
204
+ // (NON-fullscreen branch) → log-update relative cursorUp walk → one-row-low
205
+ // dip under Windows Terminal. A steady frame (shortByOne=false) fills the
206
+ // viewport, stays on the absolute cursorTo path, and is stable.
207
+ function frame({ rows, cols, palette, shortByOne, heightRows }) {
208
+ // [FAITHFUL] App.jsx pins the bottom cluster with the outer full-height
209
+ // column (height=resizeState.rows) + flexShrink={0} on the bottom bar. So the
210
+ // Yoga root height is `rows` when accounting is correct. When a reclaimed row
211
+ // is momentarily unaccounted, the laid-out tree is `rows-1` tall for one
212
+ // commit — that is `shortByOne`. In that frame the WHOLE column (including
213
+ // the pinned bottom cluster) sits one physical row higher.
214
+ // heightRows (explicit) overrides shortByOne — lets a caller drive an
215
+ // arbitrary frame height (e.g. rows-3) to exercise the real leave-fullscreen
216
+ // shrink chain, which the boolean shortByOne cannot express.
217
+ const height = heightRows != null ? heightRows : (shortByOne ? rows - 1 : rows);
218
+ const statusRow = height - 1;
219
+ const promptRow = statusRow - 1;
220
+ const lines = [];
221
+ for (let r = 0; r < height; r++) {
222
+ if (r === statusRow) { lines.push('STATUSLINE'); continue; }
223
+ if (r === promptRow) { lines.push('PROMPT>'); continue; }
224
+ if (palette && r === promptRow - 1) { lines.push('SLASHPALETTE'); continue; }
225
+ lines.push(`t${r}`);
226
+ }
227
+ // Serialized string carries exactly `height` lines (no trailing newline).
228
+ return lines.join('\n');
229
+ }
230
+
231
+ function run() {
232
+ const rows = 40, cols = 120;
233
+ const isWindows = true;
234
+ console.log(`# renderer harness rows=${rows} cols=${cols} isWindows(assumed)=${isWindows}`);
235
+ console.log(`# NOTE: log-update Windows branch active iff process.platform===win32||WT_SESSION at import`);
236
+ console.log(`# (set WT_SESSION=1 to force it on non-Windows)\n`);
237
+
238
+ // Each frame: { palette, shortByOne }. shortByOne=true is the ONE deviant
239
+ // commit where the App's row accounting leaves the laid-out tree one row
240
+ // short of the viewport (reclaimed panel/hint row not yet refilled).
241
+ const scenarios = [
242
+ { name: 'palette close, always viewport-filling (correct accounting)', seq: [
243
+ { palette: true, shortByOne: false },
244
+ { palette: false, shortByOne: false },
245
+ { palette: false, shortByOne: false },
246
+ ]},
247
+ { name: 'palette close, close commit ONE ROW SHORT (deviant accounting)', seq: [
248
+ { palette: true, shortByOne: false },
249
+ { palette: false, shortByOne: true }, // reclaimed row unaccounted
250
+ { palette: false, shortByOne: false },
251
+ ]},
252
+ { name: 'prompt newline remove, transitional ONE ROW SHORT', seq: [
253
+ { palette: false, shortByOne: false },
254
+ { palette: false, shortByOne: true },
255
+ { palette: false, shortByOne: false },
256
+ ]},
257
+ // Steady one-short: repeated rows-1 frames. The pad must fire ONCE then
258
+ // stop (lastOneShortPadded), so f2/f3 are NOT re-padded and settle at the
259
+ // rows-1 layout (prompt rows-3 / status rows-2) with correct clear
260
+ // decisions — no infinite downward shift.
261
+ { name: 'steady ONE ROW SHORT (repeated rows-1) — pad once, then stop', seq: [
262
+ { palette: false, shortByOne: false },
263
+ { palette: false, shortByOne: true },
264
+ { palette: false, shortByOne: true },
265
+ { palette: false, shortByOne: true },
266
+ ]},
267
+ // Real leave-fullscreen shrink chain rows→rows-1→rows-3: after the padded
268
+ // rows-1 frame, a further shrink to rows-3 must reach the shrink/clear path
269
+ // (not be masked by a stale pad) and settle cleanly.
270
+ { name: 'real shrink chain fullscreen→rows-1→rows-3', seq: [
271
+ { palette: false, heightRows: 40 },
272
+ { palette: false, heightRows: 39 },
273
+ { palette: false, heightRows: 37 },
274
+ { palette: false, heightRows: 37 },
275
+ ]},
276
+ ];
277
+
278
+ let anyDeviant = false;
279
+ for (const sc of scenarios) {
280
+ const { vt, commit } = makeDriver({ rows, cols, isWindows });
281
+ const promptRows = [];
282
+ console.log(`## ${sc.name}`);
283
+ sc.seq.forEach((f, idx) => {
284
+ const out = frame({ rows, cols, palette: f.palette, shortByOne: f.shortByOne, heightRows: f.heightRows });
285
+ const info = commit(out);
286
+ const pRow = vt.markerRow('PROMPT>');
287
+ const sRow = vt.markerRow('STATUSLINE');
288
+ promptRows.push(pRow);
289
+ console.log(` f${idx} short1=${f.shortByOne?1:0} h=${info.outputHeight} fs=${info.isFullscreen?1:0} trailNL=${info.trailingNL?1:0} clear=${info.clearDecision?1:0} promptRow=${pRow} statusRow=${sRow}`);
290
+ });
291
+ // Deviant = the prompt row BOUNCES (differs from the settled row for a
292
+ // transient frame then returns). A monotone shift to a new steady row
293
+ // (steady-one-short, real shrink) is NOT a bounce — check the LAST row is
294
+ // reached and held, and no interior frame differs from BOTH neighbors.
295
+ const settled = promptRows[promptRows.length - 1];
296
+ const bounce = promptRows.some((r, i) =>
297
+ i > 0 && i < promptRows.length - 1 &&
298
+ r >= 0 && r !== promptRows[i - 1] && r !== promptRows[i + 1]);
299
+ if (bounce) { anyDeviant = true; console.log(` >> DEVIANT(bounce): prompt rows ${JSON.stringify(promptRows)} vs settled ${settled}`); }
300
+ console.log('');
301
+ }
302
+ if (!anyDeviant) console.log('# no deviant frame reproduced at renderer level for these scenarios');
303
+ process.exitCode = 0;
304
+ }
305
+
306
+ run();
@@ -9,13 +9,12 @@ Bounded slices; smallest coherent change, not rewrite. Stop: unclear scope,
9
9
  growing blast radius, or Lead-only verification.
10
10
 
11
11
  EDIT-FIRST DISCIPLINE. Survey the slice with ONE batched read/grep round, then
12
- patch the first bounded piece — broad scope means edit incrementally, not read
13
- exhaustively. NEVER "one more confirming read": if an anchor is plausible, the
14
- next call is `apply_patch`. Repeated read-only turns without an edit = stalling;
15
- any runtime reminder is your cue to patch the piece you understand or report
16
- blocked — a blocked report is a valid completion. Self-check comes AFTER edits;
17
- deep verification is Lead's.
12
+ patch the first bounded piece — edit incrementally, don't read exhaustively.
13
+ NEVER "one more confirming read": a plausible anchor means the next call is
14
+ `apply_patch`. Repeated read-only turns without an edit = stalling; on a
15
+ runtime reminder, patch the piece you understand or report blocked — a valid
16
+ completion. Self-check comes AFTER edits; deep verification is Lead's.
18
17
 
19
18
  Minimal checks + how-to-verify. Hand off outcome as fragments anchored to
20
- `file:line`; no narration, no report bloat.
19
+ `file:line`; no narration or bloat.
21
20
 
@@ -8,13 +8,12 @@ Scoped implementation agent.
8
8
  Smallest scoped change; no drive-by cleanup. Stop when done/blocked.
9
9
 
10
10
  EDIT-FIRST DISCIPLINE. Brief anchors (`file:line`) are pre-verified — trust and
11
- patch. No anchor: locate with AT MOST 1-2 reads, then edit. NEVER "one more
12
- confirming read": if you know the file and the change, the next call is
13
- `apply_patch`. Repeated read-only turns without an edit = stalling; any runtime
14
- reminder is your cue to patch now or return blocked with what's missing — a
15
- blocked report is a valid completion. Threshold is "plausible", not "proven";
11
+ patch. No anchor: locate with AT MOST 1-2 reads, then edit NEVER "one more
12
+ confirming read"; if you know the file and the change, the next call is
13
+ `apply_patch`. Repeated read-only turns without an edit = stalling; on a
14
+ runtime reminder, patch now or return blocked with what's missing — a blocked
15
+ report is a valid completion. Threshold is "plausible", not "proven";
16
16
  self-check comes AFTER the edit, and Lead/Reviewer own final verification.
17
17
 
18
- Hand off outcome as fragments anchored to `file:line`; no narration, no report
19
- bloat.
18
+ Hand off outcome as fragments anchored to `file:line`; no narration or bloat.
20
19
 
@@ -182,23 +182,40 @@ function loadKeytar() {
182
182
  function keytarSync(method, ...args) {
183
183
  loadKeytar(); // throws if not installed — before spawning child
184
184
  const { spawnSync } = require('child_process');
185
- // Pass service/account/value via env to avoid shell injection entirely.
186
- const env = {
187
- ...process.env,
188
- _KEYTAR_METHOD: method,
189
- _KEYTAR_ARGS: JSON.stringify(args),
190
- };
185
+ // Pass service/account/value via stdin (not env) to avoid shell injection
186
+ // and to keep secret values out of /proc/<pid>/environ on Linux.
187
+ // Build a minimal child env instead of spreading process.env: the parent
188
+ // may hold *_API_KEY secrets in its own environment, and those would
189
+ // otherwise be visible via /proc/<pid>/environ for the lifetime of this
190
+ // child. Only pass what PATH resolution / locale / the libsecret D-Bus
191
+ // session bridge actually need.
192
+ const passthroughKeys = [
193
+ 'PATH', 'HOME', 'USER', 'LOGNAME',
194
+ 'LANG', 'LC_ALL',
195
+ 'TMPDIR', 'TMP', 'TEMP',
196
+ 'DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'XDG_RUNTIME_DIR', 'XDG_CURRENT_DESKTOP', 'XDG_DATA_DIRS',
197
+ ];
198
+ const env = { _KEYTAR_METHOD: method };
199
+ for (const key of passthroughKeys) {
200
+ if (process.env[key] !== undefined) env[key] = process.env[key];
201
+ }
191
202
  const script = [
192
203
  'const kt = require("keytar");',
193
204
  'const method = process.env._KEYTAR_METHOD;',
194
- 'const args = JSON.parse(process.env._KEYTAR_ARGS);',
195
- 'kt[method](...args)',
196
- ' .then(v => { process.stdout.write(JSON.stringify({ ok: true, value: v })); })',
197
- ' .catch(e => { process.stdout.write(JSON.stringify({ ok: false, error: e.message })); });',
205
+ 'let input = "";',
206
+ 'process.stdin.setEncoding("utf8");',
207
+ 'process.stdin.on("data", (chunk) => { input += chunk; });',
208
+ 'process.stdin.on("end", () => {',
209
+ ' const args = JSON.parse(input);',
210
+ ' kt[method](...args)',
211
+ ' .then(v => { process.stdout.write(JSON.stringify({ ok: true, value: v })); })',
212
+ ' .catch(e => { process.stdout.write(JSON.stringify({ ok: false, error: e.message })); });',
213
+ '});',
198
214
  ].join(' ');
199
215
  const r = spawnSync(process.execPath, ['-e', script], {
200
216
  env,
201
- stdio: ['ignore', 'pipe', 'pipe'],
217
+ input: JSON.stringify(args),
218
+ stdio: ['pipe', 'pipe', 'pipe'],
202
219
  timeout: 5000,
203
220
  encoding: 'utf8',
204
221
  windowsHide: true,
@@ -129,10 +129,10 @@ function buildProfilePreferencesContent(dataDir) {
129
129
  const profile = normalizeProfileConfig(readAgentConfig(dataDir).profile);
130
130
  const lines = [];
131
131
  if (profile.title) {
132
- lines.push(`- Use "${profile.title}" when directly addressing the user (greetings, answers, questions). Do not repeat it in routine progress updates or pre-tool preambles.`);
132
+ lines.push(`- Use "${profile.title}" when directly addressing the user (greetings, answers, questions); do not repeat it in routine progress updates or pre-tool preambles.`);
133
133
  }
134
134
  const shell = process.platform === 'win32' ? 'powershell' : 'bash';
135
- lines.push(`- Shell environment: ${shell}. When using shell, write commands and scripts in ${shell} syntax unless the user specifies otherwise. Keep commands, paths, symbols, and exact errors verbatim.`);
135
+ lines.push(`- Shell environment: ${shell}. Write shell commands and scripts in ${shell} syntax unless the user specifies otherwise; keep commands, paths, symbols, and exact errors verbatim.`);
136
136
  return lines.length ? `# Profile Preferences\n\n${lines.join('\n')}` : '';
137
137
  }
138
138
 
@@ -144,7 +144,7 @@ function buildLanguageSection(dataDir) {
144
144
  ? ` from system locale ${language.locale}`
145
145
  : '';
146
146
  const lines = [
147
- `- Default user-facing response language${source}: ${language.prompt}. Use it for ALL user-facing prose, including pre-tool preambles, progress updates, questions, final reports, and notices. Every such message even a single-line preamble before a tool call — MUST be written in ${language.prompt}, and in no other language. This overrides any tone implied by the output style. Switch languages only when the user writes in another language or explicitly asks you to.`,
147
+ `- Default user-facing response language${source}: ${language.prompt}. EVERY user-facing message — prose, pre-tool preambles (even single-line), progress updates, questions, final reports, notices — MUST be written in ${language.prompt} and no other language; this overrides any tone implied by the output style. Switch only when the user writes in another language or explicitly asks you to.`,
148
148
  `- Code identifiers, paths, commands, symbols, API names, and exact errors should remain in their original form.`,
149
149
  ];
150
150
  return `# Language\n\n${lines.join('\n')}`;
@@ -382,13 +382,9 @@ function buildAgentRoleContent({ PLUGIN_ROOT, profile = 'full' }) {
382
382
  function buildAgentRetrievalInjectionContent({ PLUGIN_ROOT }) {
383
383
  const AGENT_DIR = path.join(PLUGIN_ROOT, 'rules', 'agent');
384
384
  const core = readOptional(path.join(AGENT_DIR, '00-core.md'));
385
- const parts = [
386
- '# Tool Use',
387
- '',
388
- '- Batch independent read-only lookups in the same tool turn.',
389
- '- Use code_graph for symbols/dependencies, grep for exact text, find/glob/list for files, and read only known paths/windows.',
390
- '',
391
- ];
385
+ // Full shared tool policy (01-tool.md) now ships via BP1 for retrieval
386
+ // roles too; no compact duplicate here.
387
+ const parts = [];
392
388
  if (core) parts.push(core.trim());
393
389
  parts.push('', '- Read-only retrieval role: do not edit files, run shell, or use git.');
394
390
  return parts.join('\n');
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
3
3
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { performance } from 'node:perf_hooks';
6
+ import httpMod from 'node:http';
6
7
  import { ensureStandaloneEnvironment } from './standalone/seeds.mjs';
7
8
  import { createStandaloneAgent } from './standalone/agent-tool.mjs';
8
9
  import { isAgentOwner } from './runtime/agent/orchestrator/agent-owner.mjs';
@@ -167,6 +168,9 @@ import {
167
168
  normalizePluginMcpServerConfig,
168
169
  pluginManifest,
169
170
  pluginMcpServerName,
171
+ pluginRawMcpServers,
172
+ pluginMcpEnableScript,
173
+ resolveContainedPluginPath,
170
174
  } from './session-runtime/plugin-mcp.mjs';
171
175
  import {
172
176
  WORKFLOW_ROUTE_SLOTS,
@@ -395,6 +399,13 @@ export async function createMixdogSessionRuntime({
395
399
  ]);
396
400
  bootProfile('imports:ready', { ms: (performance.now() - importsStartedAt).toFixed(1) });
397
401
  const pluginDataDir = cfgMod.getPluginData();
402
+ // Re-wire the idle/tombstone sweep. startIdleCleanup() lost its caller in a
403
+ // refactor, so closed-session tombstones were never deleted after their 24h
404
+ // grace — the store grew unbounded (observed: 1.8k files / 114MB), which
405
+ // made summary-index rebuilds and per-save index rewrites stall boot for
406
+ // seconds. Timer is unref'd and first fires after CLEANUP_INITIAL_DELAY_MS
407
+ // (5min), so this adds zero boot-path cost.
408
+ try { mgr.startIdleCleanup?.(); } catch { /* cleanup is best-effort */ }
398
409
  const memoryRuntime = createStandaloneMemoryRuntime({
399
410
  entry: join(STANDALONE_ROOT, MEMORY_RUNTIME.replace(/^\.\//, '')),
400
411
  dataDir: pluginDataDir,
@@ -1531,11 +1542,42 @@ export async function createMixdogSessionRuntime({
1531
1542
  // published to active-instance.json by getMemoryModule().init(). Without
1532
1543
  // this, memory only starts on the first turn's getMemoryModule() call —
1533
1544
  // so early channel traffic finds no memory_port and gets buffered (or,
1534
- // pre-drainer, silently dropped). Fire-and-forget BEFORE channel claim so
1535
- // the port is (racing to be) live by the time the worker sends its first
1536
- // ingest.
1537
- try { void getMemoryModule().catch((error) => bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) })); }
1538
- catch (error) { bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) }); }
1545
+ // pre-drainer, silently dropped). Runs BEFORE channel claim so the port is
1546
+ // racing to be live by the time the worker sends its first ingest.
1547
+ //
1548
+ // Not fire-and-forget: init() only resolves the module handle, so a bare
1549
+ // getMemoryModule() could return before /health reports ok leaving early
1550
+ // ingests to hit a not-yet-listening port. Await the proxy start() and then
1551
+ // poll /health with a bounded retry so the daemon is provably reachable
1552
+ // (or logged failed) rather than assumed-up. Still non-blocking to the
1553
+ // caller: the whole probe is detached, but it internally awaits readiness.
1554
+ void (async () => {
1555
+ try {
1556
+ const mod = await getMemoryModule();
1557
+ const started = typeof mod?.start === 'function' ? await mod.start() : null;
1558
+ const port = started?.port;
1559
+ if (!port) { bootProfile('channels:memory-eager-init-failed', { error: 'no port from start()' }); return; }
1560
+ for (let i = 0; i < 30; i++) {
1561
+ try {
1562
+ const ok = await new Promise((res) => {
1563
+ const req = httpMod.request({ hostname: '127.0.0.1', port, path: '/health', timeout: 1500 }, (r) => {
1564
+ let d = ''; r.on('data', (c) => { d += c; }); r.on('end', () => {
1565
+ try { res(JSON.parse(d)?.status === 'ok'); } catch { res(false); }
1566
+ });
1567
+ });
1568
+ req.on('error', () => res(false));
1569
+ req.on('timeout', () => { req.destroy(); res(false); });
1570
+ req.end();
1571
+ });
1572
+ if (ok) { bootProfile('channels:memory-eager-init-ready', { port }); return; }
1573
+ } catch {}
1574
+ await new Promise((r) => setTimeout(r, 500));
1575
+ }
1576
+ bootProfile('channels:memory-eager-init-failed', { error: `health not ok after retries (port ${port})` });
1577
+ } catch (error) {
1578
+ bootProfile('channels:memory-eager-init-failed', { error: error?.message || String(error) });
1579
+ }
1580
+ })();
1539
1581
  // Publish this session's record + transcript file BEFORE the worker's
1540
1582
  // activate-time discovery polls, so output forwarding binds to this
1541
1583
  // terminal session immediately instead of waiting for the first turn.
@@ -1640,8 +1682,38 @@ export async function createMixdogSessionRuntime({
1640
1682
  // channel; see startRemote()/stopRemote() and the `/remote` toggle.
1641
1683
  // `remote.autoStart` in mixdog-config.json makes every session claim remote
1642
1684
  // at boot (same force-takeover semantics as `mixdog --remote` / `/remote`).
1643
- if (!remoteEnabled && config?.remote?.autoStart === true) remoteEnabled = true;
1644
- if (remoteEnabled) startRemote();
1685
+ // The flag lives in the TOP-LEVEL `remote` section of mixdog-config.json
1686
+ // (sibling of agent/ui/channels), not inside the agent section that
1687
+ // cfgMod.loadConfig returns — read it via the shared whole-file reader.
1688
+ // `config?.remote?.autoStart` is kept for back-compat with agent-section
1689
+ // placement.
1690
+ if (!remoteEnabled) {
1691
+ try {
1692
+ if (config?.remote?.autoStart === true
1693
+ || sharedCfgMod?.readSection?.('remote')?.autoStart === true) {
1694
+ remoteEnabled = true;
1695
+ }
1696
+ } catch { /* unreadable config never blocks boot */ }
1697
+ }
1698
+ // Boot-time remote start (autoStart or --remote) is DEFERRED past the TUI's
1699
+ // first frame. startRemote() front-loads heavy work — memory daemon fork
1700
+ // (PG + forced ONNX embed warmup in the child), eager session create, and
1701
+ // the channel-worker fork — and running it inline here interleaves that
1702
+ // CPU/disk load with engine boot, visibly delaying the first ink frame by
1703
+ // seconds. The deferred timer reuses prewarmTimers.channelStartTimer so an
1704
+ // early /remote (startRemote clears it), stopRemote(), and close() all
1705
+ // cancel it through the existing clearTimeout paths. Runtime /remote calls
1706
+ // still start immediately (user-initiated, UI already painted).
1707
+ if (remoteEnabled) {
1708
+ const remoteAutoStartDelayMs = envDelayMs('MIXDOG_REMOTE_AUTOSTART_DELAY_MS', 1_500, { min: 0, max: 60_000 });
1709
+ bootProfile('channels:autostart-deferred', { delayMs: remoteAutoStartDelayMs });
1710
+ prewarmTimers.channelStartTimer = setTimeout(() => {
1711
+ prewarmTimers.channelStartTimer = null;
1712
+ if (closeRequested || !remoteEnabled) return;
1713
+ startRemote();
1714
+ }, remoteAutoStartDelayMs);
1715
+ prewarmTimers.channelStartTimer.unref?.();
1716
+ }
1645
1717
 
1646
1718
  function contextStatusCacheKeyFor({ messages, tools }) {
1647
1719
  const compaction = session?.compaction || {};
@@ -2769,19 +2841,17 @@ export async function createMixdogSessionRuntime({
2769
2841
  },
2770
2842
  async enablePluginMcp(plugin = {}) {
2771
2843
  const root = clean(plugin.root);
2772
- const script = clean(plugin.mcpScript);
2844
+ const script = pluginMcpEnableScript(root, plugin);
2773
2845
  if (!root || !script) throw new Error('plugin has no MCP script');
2774
2846
  const serverName = pluginMcpServerName(plugin);
2775
2847
  const nextConfig = { ...config };
2776
- if (script === '.mcp.json') {
2777
- const mcpJsonPath = join(root, script);
2778
- if (!existsSync(mcpJsonPath)) throw new Error(`plugin MCP manifest not found: ${mcpJsonPath}`);
2779
- const manifest = readJsonSafe(mcpJsonPath) || {};
2780
- const rawServers = manifest.mcpServers;
2781
- const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
2782
- if (!isPlainObject(rawServers)) throw new Error(`plugin .mcp.json missing mcpServers object: ${mcpJsonPath}`);
2783
- const keys = Object.keys(rawServers).filter((k) => isPlainObject(rawServers[k]));
2784
- if (!keys.length) throw new Error(`plugin .mcp.json has no mcpServers: ${mcpJsonPath}`);
2848
+ const manifestMcp = pluginRawMcpServers(root, script);
2849
+ if (manifestMcp) {
2850
+ const { rawServers, mcpRoot } = manifestMcp;
2851
+ const keys = Object.keys(rawServers).filter((k) => {
2852
+ const v = rawServers[k];
2853
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
2854
+ });
2785
2855
  const ownedPrefix = `${serverName}--`;
2786
2856
  const nextServers = {};
2787
2857
  for (const [k, v] of Object.entries(nextConfig.mcpServers || {})) {
@@ -2789,7 +2859,7 @@ export async function createMixdogSessionRuntime({
2789
2859
  nextServers[k] = v;
2790
2860
  }
2791
2861
  for (const serverKey of keys) {
2792
- const cfg = normalizePluginMcpServerConfig(rawServers[serverKey], root);
2862
+ const cfg = normalizePluginMcpServerConfig(rawServers[serverKey], mcpRoot);
2793
2863
  cfg.env = {
2794
2864
  ...(cfg.env || {}),
2795
2865
  MIXDOG_PLUGIN_ROOT: root,
@@ -2800,8 +2870,8 @@ export async function createMixdogSessionRuntime({
2800
2870
  }
2801
2871
  nextConfig.mcpServers = nextServers;
2802
2872
  } else {
2803
- const scriptPath = join(root, script);
2804
- if (!existsSync(scriptPath)) throw new Error(`plugin MCP script not found: ${scriptPath}`);
2873
+ const scriptPath = resolveContainedPluginPath(root, script);
2874
+ if (!scriptPath || !existsSync(scriptPath)) throw new Error(`plugin MCP script not found: ${join(root, script)}`);
2805
2875
  nextConfig.mcpServers = {
2806
2876
  ...(nextConfig.mcpServers || {}),
2807
2877
  [serverName]: {