u-foo 1.0.6 → 1.1.9

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 (149) hide show
  1. package/README.md +44 -4
  2. package/SKILLS/ufoo/SKILL.md +17 -2
  3. package/SKILLS/uinit/SKILL.md +8 -3
  4. package/bin/ucode-core.js +15 -0
  5. package/bin/ucode.js +125 -0
  6. package/bin/ufoo-assistant-agent.js +5 -0
  7. package/bin/ufoo-engine.js +25 -0
  8. package/bin/ufoo.js +4 -0
  9. package/modules/AGENTS.template.md +14 -4
  10. package/modules/bus/README.md +8 -5
  11. package/modules/bus/SKILLS/ubus/SKILL.md +5 -4
  12. package/modules/context/SKILLS/uctx/SKILL.md +3 -1
  13. package/modules/online/SKILLS/ufoo-online/SKILL.md +144 -0
  14. package/package.json +12 -3
  15. package/scripts/import-pi-mono.js +124 -0
  16. package/scripts/postinstall.js +20 -49
  17. package/scripts/sync-claude-skills.sh +21 -0
  18. package/src/agent/cliRunner.js +524 -31
  19. package/src/agent/internalRunner.js +76 -9
  20. package/src/agent/launcher.js +97 -45
  21. package/src/agent/normalizeOutput.js +1 -1
  22. package/src/agent/notifier.js +144 -4
  23. package/src/agent/ptyRunner.js +480 -10
  24. package/src/agent/ptyWrapper.js +28 -3
  25. package/src/agent/readyDetector.js +16 -0
  26. package/src/agent/ucode.js +443 -0
  27. package/src/agent/ucodeBootstrap.js +113 -0
  28. package/src/agent/ucodeBuild.js +67 -0
  29. package/src/agent/ucodeDoctor.js +184 -0
  30. package/src/agent/ucodeRuntimeConfig.js +129 -0
  31. package/src/agent/ufooAgent.js +11 -2
  32. package/src/assistant/agent.js +260 -0
  33. package/src/assistant/bridge.js +172 -0
  34. package/src/assistant/engine.js +252 -0
  35. package/src/assistant/stdio.js +58 -0
  36. package/src/assistant/ufooEngineCli.js +306 -0
  37. package/src/bus/activate.js +27 -11
  38. package/src/bus/daemon.js +133 -5
  39. package/src/bus/index.js +137 -80
  40. package/src/bus/inject.js +47 -17
  41. package/src/bus/message.js +145 -17
  42. package/src/bus/nickname.js +3 -1
  43. package/src/bus/queue.js +6 -1
  44. package/src/bus/store.js +189 -0
  45. package/src/bus/subscriber.js +20 -4
  46. package/src/bus/utils.js +9 -3
  47. package/src/chat/agentBar.js +117 -0
  48. package/src/chat/agentDirectory.js +88 -0
  49. package/src/chat/agentSockets.js +225 -0
  50. package/src/chat/agentViewController.js +298 -0
  51. package/src/chat/chatLogController.js +115 -0
  52. package/src/chat/commandExecutor.js +700 -0
  53. package/src/chat/commands.js +132 -0
  54. package/src/chat/completionController.js +414 -0
  55. package/src/chat/cronScheduler.js +160 -0
  56. package/src/chat/daemonConnection.js +166 -0
  57. package/src/chat/daemonCoordinator.js +64 -0
  58. package/src/chat/daemonMessageRouter.js +257 -0
  59. package/src/chat/daemonReconnect.js +41 -0
  60. package/src/chat/daemonTransport.js +36 -0
  61. package/src/chat/daemonTransportDefaults.js +10 -0
  62. package/src/chat/dashboardKeyController.js +480 -0
  63. package/src/chat/dashboardView.js +154 -0
  64. package/src/chat/index.js +935 -2909
  65. package/src/chat/inputHistoryController.js +105 -0
  66. package/src/chat/inputListenerController.js +304 -0
  67. package/src/chat/inputMath.js +104 -0
  68. package/src/chat/inputSubmitHandler.js +171 -0
  69. package/src/chat/layout.js +165 -0
  70. package/src/chat/pasteController.js +81 -0
  71. package/src/chat/rawKeyMap.js +42 -0
  72. package/src/chat/settingsController.js +132 -0
  73. package/src/chat/statusLineController.js +177 -0
  74. package/src/chat/streamTracker.js +138 -0
  75. package/src/chat/text.js +70 -0
  76. package/src/chat/transport.js +61 -0
  77. package/src/cli/busCoreCommands.js +59 -0
  78. package/src/cli/ctxCoreCommands.js +199 -0
  79. package/src/cli/onlineCoreCommands.js +379 -0
  80. package/src/cli.js +741 -238
  81. package/src/code/README.md +29 -0
  82. package/src/code/UCODE_PROMPT.md +32 -0
  83. package/src/code/agent.js +1651 -0
  84. package/src/code/cli.js +158 -0
  85. package/src/code/config +0 -0
  86. package/src/code/dispatch.js +42 -0
  87. package/src/code/index.js +70 -0
  88. package/src/code/nativeRunner.js +1213 -0
  89. package/src/code/runtime.js +154 -0
  90. package/src/code/sessionStore.js +162 -0
  91. package/src/code/taskDecomposer.js +269 -0
  92. package/src/code/tools/bash.js +53 -0
  93. package/src/code/tools/common.js +42 -0
  94. package/src/code/tools/edit.js +70 -0
  95. package/src/code/tools/read.js +44 -0
  96. package/src/code/tools/write.js +35 -0
  97. package/src/code/tui.js +1580 -0
  98. package/src/config.js +47 -1
  99. package/src/context/decisions.js +12 -2
  100. package/src/context/index.js +18 -1
  101. package/src/context/sync.js +127 -0
  102. package/src/daemon/agentProcessManager.js +74 -0
  103. package/src/daemon/cronOps.js +241 -0
  104. package/src/daemon/index.js +661 -488
  105. package/src/daemon/ipcServer.js +99 -0
  106. package/src/daemon/ops.js +417 -179
  107. package/src/daemon/promptLoop.js +319 -0
  108. package/src/daemon/promptRequest.js +101 -0
  109. package/src/daemon/providerSessions.js +32 -17
  110. package/src/daemon/reporting.js +90 -0
  111. package/src/daemon/run.js +2 -5
  112. package/src/daemon/status.js +24 -1
  113. package/src/init/index.js +68 -14
  114. package/src/online/bridge.js +663 -0
  115. package/src/online/client.js +245 -0
  116. package/src/online/runner.js +253 -0
  117. package/src/online/server.js +992 -0
  118. package/src/online/tokens.js +103 -0
  119. package/src/report/store.js +331 -0
  120. package/src/shared/eventContract.js +35 -0
  121. package/src/shared/ptySocketContract.js +21 -0
  122. package/src/status/index.js +50 -17
  123. package/src/terminal/adapterContract.js +87 -0
  124. package/src/terminal/adapterRouter.js +84 -0
  125. package/src/terminal/adapters/externalAdapter.js +14 -0
  126. package/src/terminal/adapters/internalAdapter.js +13 -0
  127. package/src/terminal/adapters/internalPtyAdapter.js +42 -0
  128. package/src/terminal/adapters/internalQueueAdapter.js +37 -0
  129. package/src/terminal/adapters/terminalAdapter.js +31 -0
  130. package/src/terminal/adapters/tmuxAdapter.js +30 -0
  131. package/src/ufoo/agentsStore.js +69 -3
  132. package/src/utils/banner.js +5 -2
  133. package/scripts/.archived/bash-to-js-migration/README.md +0 -46
  134. package/scripts/.archived/bash-to-js-migration/banner.sh +0 -89
  135. package/scripts/.archived/bash-to-js-migration/bus-alert.sh +0 -6
  136. package/scripts/.archived/bash-to-js-migration/bus-autotrigger.sh +0 -6
  137. package/scripts/.archived/bash-to-js-migration/bus-daemon.sh +0 -231
  138. package/scripts/.archived/bash-to-js-migration/bus-inject.sh +0 -176
  139. package/scripts/.archived/bash-to-js-migration/bus-listen.sh +0 -6
  140. package/scripts/.archived/bash-to-js-migration/bus.sh +0 -986
  141. package/scripts/.archived/bash-to-js-migration/context-decisions.sh +0 -167
  142. package/scripts/.archived/bash-to-js-migration/context-doctor.sh +0 -72
  143. package/scripts/.archived/bash-to-js-migration/context-lint.sh +0 -110
  144. package/scripts/.archived/bash-to-js-migration/doctor.sh +0 -22
  145. package/scripts/.archived/bash-to-js-migration/init.sh +0 -247
  146. package/scripts/.archived/bash-to-js-migration/skills.sh +0 -113
  147. package/scripts/.archived/bash-to-js-migration/status.sh +0 -125
  148. package/scripts/banner.sh +0 -2
  149. package/src/bus/API_DESIGN.md +0 -204
@@ -1,6 +1,9 @@
1
1
  const fs = require("fs");
2
2
  const path = require("path");
3
+ const net = require("net");
4
+ const { spawnSync } = require("child_process");
3
5
  const EventBus = require("../bus");
6
+ const { PTY_SOCKET_MESSAGE_TYPES, PTY_SOCKET_SUBSCRIBE_MODES } = require("../shared/ptySocketContract");
4
7
  const { runInternalRunner } = require("./internalRunner");
5
8
  const { getUfooPaths } = require("../ufoo/paths");
6
9
 
@@ -87,15 +90,19 @@ function buildPrompt(text, marker) {
87
90
  }
88
91
 
89
92
  function resolveCommand(agentType) {
93
+ const normalizedAgent = String(agentType || "").trim().toLowerCase();
90
94
  const rawCmd = String(process.env.UFOO_PTY_CMD || "").trim();
91
95
  if (rawCmd) {
92
96
  const rawArgs = String(process.env.UFOO_PTY_ARGS || "").trim();
93
97
  const args = rawArgs ? rawArgs.split(/\s+/).filter(Boolean) : [];
94
98
  return { command: rawCmd, args };
95
99
  }
96
- if (agentType === "claude" || agentType === "claude-code") {
100
+ if (normalizedAgent === "claude" || normalizedAgent === "claude-code") {
97
101
  return { command: "claude", args: [] };
98
102
  }
103
+ if (normalizedAgent === "ufoo" || normalizedAgent === "ucode" || normalizedAgent === "ufoo-code") {
104
+ return { command: "ucode", args: [] };
105
+ }
99
106
  return { command: "codex", args: ["--no-alt-screen", "--sandbox", "workspace-write"] };
100
107
  }
101
108
 
@@ -107,11 +114,23 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
107
114
  } catch {
108
115
  throw new Error("node-pty not installed");
109
116
  }
117
+ let Terminal = null;
118
+ let SerializeAddon = null;
119
+ try {
120
+ const xterm = await import("xterm-headless");
121
+ const serialize = await import("xterm-addon-serialize");
122
+ Terminal = xterm.Terminal || (xterm.default && xterm.default.Terminal);
123
+ SerializeAddon = serialize.SerializeAddon || (serialize.default && serialize.default.SerializeAddon);
124
+ } catch {
125
+ Terminal = null;
126
+ SerializeAddon = null;
127
+ }
110
128
  const { subscriber } = parseSubscriberId();
111
129
  const queueDir = path.join(getUfooPaths(projectRoot).busQueuesDir, safeSubscriber(subscriber));
112
130
  const queueFile = path.join(queueDir, "pending.jsonl");
113
131
  const runDir = getUfooPaths(projectRoot).runDir;
114
132
  const logFile = path.join(runDir, "pty-runner.log");
133
+ const injectSockPath = path.join(queueDir, "inject.sock");
115
134
 
116
135
  const { command, args } = resolveCommand(agentType);
117
136
  const env = {
@@ -124,6 +143,9 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
124
143
 
125
144
  let running = true;
126
145
  let busy = false;
146
+ let ptyAlive = false;
147
+ let ptyReady = false;
148
+ let readyTimer = null;
127
149
  let currentPublisher = "";
128
150
  let currentMarker = "";
129
151
  let pendingOutput = [];
@@ -131,19 +153,291 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
131
153
  let flushTimer = null;
132
154
  let idleTimer = null;
133
155
  let watchdogTimer = null;
156
+ let suppressEcho = false;
157
+ let echoMarker = "";
158
+ let suppressTimer = null;
134
159
  let fallbackInProgress = false;
135
160
  let ptyProcess = null;
161
+ let restartCount = 0;
162
+ let lastSpawnTime = 0;
163
+ const MAX_RESTARTS = 3;
164
+ const RESTART_STABLE_MS = 30000; // reset counter if process ran > 30s
165
+ const RESTART_DELAY_MS = 2000;
166
+ const READY_QUIET_MS = 3000; // TUI is "ready" after 3s of no output
136
167
  const messageQueue = [];
168
+ const injectServer = setupInjectServer();
169
+ initScreenBuffer(80, 24);
137
170
  const maxChunk = 2000;
138
- const idleMs = 2000;
171
+ const idleMs = 30000;
139
172
  const watchdogMs = 120000;
140
173
  const maxQueue = 200;
141
174
  const watchdogAction = String(process.env.UFOO_PTY_WATCHDOG_ACTION || "restart").toLowerCase();
142
175
  let sendQueue = Promise.resolve();
176
+ const DROP_LINE_PATTERNS = [
177
+ /__UFOO_DONE_/,
178
+ /请在完成后输出以下标记/,
179
+ /context left/i,
180
+ /esc to interrupt/i,
181
+ /for shortcuts/i,
182
+ /Preparing to run session start commands/i,
183
+ ];
184
+
185
+ function shouldDropLine(line) {
186
+ if (!line) return true;
187
+ const trimmed = line.trim();
188
+ if (!trimmed) return true;
189
+ if (/^[›❯>]$/.test(trimmed)) return true;
190
+ return DROP_LINE_PATTERNS.some((re) => re.test(trimmed));
191
+ }
192
+
193
+ function sanitizeChunk(chunk) {
194
+ if (!chunk) return "";
195
+ let text = String(chunk);
196
+ if (text.includes("\r")) {
197
+ const parts = text.split("\r");
198
+ text = parts[parts.length - 1];
199
+ }
200
+ const lines = text.split("\n").filter((line) => !shouldDropLine(line));
201
+ return lines.join("\n");
202
+ }
143
203
 
144
204
  function enqueueSend(target, message) {
145
205
  if (!target || !message) return;
146
- sendQueue = sendQueue.then(() => eventBus.send(target, message, subscriber)).catch(() => {});
206
+ sendQueue = sendQueue.then(() => eventBus.send(target, message, subscriber)).catch((err) => {
207
+ logNote(`[send-error] target=${target} err=${err.message || err}`);
208
+ });
209
+ }
210
+
211
+ // TTY view subscribers (same protocol as launcher inject.sock)
212
+ const outputSubscribers = new Set();
213
+ let term = null;
214
+ let serializeAddon = null;
215
+ let termWriteQueue = Promise.resolve();
216
+ const OUTPUT_RING_MAX = (() => {
217
+ const env = Number.parseInt(process.env.UFOO_INTERNAL_RING_MAX || "", 10);
218
+ if (Number.isFinite(env) && env > 0) return env;
219
+ return 512 * 1024;
220
+ })();
221
+ let outputRingBuffer = "";
222
+
223
+ function initScreenBuffer(cols = 80, rows = 24) {
224
+ if (!Terminal || !SerializeAddon) return null;
225
+ try {
226
+ const scrollbackEnv = Number.parseInt(process.env.UFOO_INTERNAL_SCROLLBACK || "", 10);
227
+ const scrollback = Number.isFinite(scrollbackEnv) && scrollbackEnv >= 0
228
+ ? scrollbackEnv
229
+ : 20000;
230
+ term = new Terminal({
231
+ cols,
232
+ rows,
233
+ scrollback,
234
+ allowProposedApi: true,
235
+ convertEol: true,
236
+ });
237
+ serializeAddon = new SerializeAddon();
238
+ term.loadAddon(serializeAddon);
239
+ } catch {
240
+ term = null;
241
+ serializeAddon = null;
242
+ }
243
+ return term;
244
+ }
245
+
246
+ function enqueueTermWrite(data) {
247
+ if (!term || !data) return;
248
+ termWriteQueue = termWriteQueue.then(() => new Promise((resolve) => {
249
+ term.write(data, resolve);
250
+ })).catch(() => {});
251
+ }
252
+
253
+ function serializeBuffer(buffer, scrollback) {
254
+ if (!term || !serializeAddon || !buffer) return "";
255
+ try {
256
+ if (typeof serializeAddon._serializeBuffer === "function") {
257
+ return serializeAddon._serializeBuffer(term, buffer, scrollback);
258
+ }
259
+ if (buffer === term.buffer.normal && typeof serializeAddon.serialize === "function") {
260
+ return serializeAddon.serialize({
261
+ scrollback,
262
+ excludeAltBuffer: true,
263
+ excludeModes: true,
264
+ });
265
+ }
266
+ return "";
267
+ } catch {
268
+ return "";
269
+ }
270
+ }
271
+
272
+ async function serializeSnapshot(mode = "full") {
273
+ if (!term || !serializeAddon) return null;
274
+ try {
275
+ await termWriteQueue;
276
+ const active = term.buffer.active;
277
+ const normal = term.buffer.normal;
278
+ const scrollback = term.options && Number.isFinite(term.options.scrollback)
279
+ ? term.options.scrollback
280
+ : undefined;
281
+
282
+ if (mode === "screen") {
283
+ const screen = serializeBuffer(active, 0);
284
+ return screen ? { data: screen } : null;
285
+ }
286
+
287
+ let data = serializeBuffer(normal, scrollback);
288
+ if (active && active !== normal) {
289
+ const alt = serializeBuffer(active, 0);
290
+ if (alt) data += `\x1b[H${alt}`;
291
+ }
292
+ return data ? { data } : null;
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ function broadcastOutput(data) {
299
+ const text = Buffer.from(data || "").toString("utf8");
300
+ if (!text) return;
301
+ enqueueTermWrite(text);
302
+ outputRingBuffer += text;
303
+ if (outputRingBuffer.length > OUTPUT_RING_MAX) {
304
+ outputRingBuffer = outputRingBuffer.slice(-OUTPUT_RING_MAX);
305
+ }
306
+ if (outputSubscribers.size === 0) return;
307
+ const msg = JSON.stringify({ type: PTY_SOCKET_MESSAGE_TYPES.OUTPUT, data: text, encoding: "utf8" }) + "\n";
308
+ for (const sub of outputSubscribers) {
309
+ try {
310
+ sub.write(msg);
311
+ } catch {
312
+ outputSubscribers.delete(sub);
313
+ }
314
+ }
315
+ }
316
+
317
+ function setupInjectServer() {
318
+ const dir = path.dirname(injectSockPath);
319
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
320
+ if (fs.existsSync(injectSockPath)) {
321
+ try { fs.unlinkSync(injectSockPath); } catch { /* ignore */ }
322
+ }
323
+ const server = net.createServer((client) => {
324
+ let buffer = "";
325
+ client.on("data", (data) => {
326
+ buffer += data.toString("utf8");
327
+ const lines = buffer.split("\n");
328
+ buffer = lines.pop() || "";
329
+ for (const line of lines) {
330
+ if (!line.trim()) continue;
331
+ try {
332
+ const req = JSON.parse(line);
333
+ if (req.type === "inject" && req.command) {
334
+ if (ptyProcess && ptyAlive) {
335
+ ptyProcess.write(String(req.command));
336
+ setTimeout(() => {
337
+ if (!ptyProcess || !ptyAlive) return;
338
+ ptyProcess.write("\x1b");
339
+ setTimeout(() => {
340
+ if (ptyProcess && ptyAlive) {
341
+ ptyProcess.write("\r");
342
+ }
343
+ }, 100);
344
+ }, 200);
345
+ client.write(JSON.stringify({ ok: true }) + "\n");
346
+ } else {
347
+ client.write(JSON.stringify({ ok: false, error: "pty not ready" }) + "\n");
348
+ }
349
+ } else if (req.type === PTY_SOCKET_MESSAGE_TYPES.RAW && typeof req.data === "string") {
350
+ if (ptyProcess && ptyAlive) {
351
+ ptyProcess.write(req.data);
352
+ client.write(JSON.stringify({ ok: true }) + "\n");
353
+ } else {
354
+ client.write(JSON.stringify({ ok: false, error: "pty not ready" }) + "\n");
355
+ }
356
+ } else if (req.type === PTY_SOCKET_MESSAGE_TYPES.RESIZE && req.cols && req.rows) {
357
+ if (ptyProcess && ptyAlive && typeof ptyProcess.resize === "function") {
358
+ ptyProcess.resize(req.cols, req.rows);
359
+ }
360
+ if (term && typeof term.resize === "function") {
361
+ try { term.resize(req.cols, req.rows); } catch { /* ignore */ }
362
+ }
363
+ client.write(JSON.stringify({ ok: true }) + "\n");
364
+ } else if (req.type === PTY_SOCKET_MESSAGE_TYPES.SUBSCRIBE) {
365
+ outputSubscribers.add(client);
366
+ client.write(JSON.stringify({ type: PTY_SOCKET_MESSAGE_TYPES.SUBSCRIBED, ok: true }) + "\n");
367
+ const mode = req.mode === PTY_SOCKET_SUBSCRIBE_MODES.SCREEN
368
+ ? PTY_SOCKET_SUBSCRIBE_MODES.SCREEN
369
+ : PTY_SOCKET_SUBSCRIBE_MODES.FULL;
370
+ if (mode === PTY_SOCKET_SUBSCRIBE_MODES.FULL) {
371
+ if (outputRingBuffer.length > 0) {
372
+ try {
373
+ client.write(JSON.stringify({
374
+ type: PTY_SOCKET_MESSAGE_TYPES.REPLAY,
375
+ data: outputRingBuffer,
376
+ encoding: "utf8",
377
+ }) + "\n");
378
+ } catch {
379
+ // ignore replay send errors
380
+ }
381
+ } else {
382
+ serializeSnapshot(PTY_SOCKET_SUBSCRIBE_MODES.FULL).then((snapshot) => {
383
+ if (snapshot && snapshot.data) {
384
+ try {
385
+ client.write(JSON.stringify({
386
+ type: PTY_SOCKET_MESSAGE_TYPES.SNAPSHOT,
387
+ data: snapshot.data,
388
+ encoding: "utf8",
389
+ }) + "\n");
390
+ } catch {
391
+ // ignore snapshot send errors
392
+ }
393
+ }
394
+ }).catch(() => {});
395
+ }
396
+ } else {
397
+ serializeSnapshot(PTY_SOCKET_SUBSCRIBE_MODES.SCREEN).then((snapshot) => {
398
+ if (snapshot && snapshot.data) {
399
+ try {
400
+ client.write(JSON.stringify({
401
+ type: PTY_SOCKET_MESSAGE_TYPES.SNAPSHOT,
402
+ data: snapshot.data,
403
+ encoding: "utf8",
404
+ }) + "\n");
405
+ } catch {
406
+ // ignore snapshot send errors
407
+ }
408
+ }
409
+ }).catch(() => {});
410
+ }
411
+ } else {
412
+ client.write(JSON.stringify({ ok: false, error: "invalid request" }) + "\n");
413
+ }
414
+ } catch (err) {
415
+ client.write(JSON.stringify({ ok: false, error: err.message }) + "\n");
416
+ }
417
+ }
418
+ });
419
+ client.on("error", () => {
420
+ outputSubscribers.delete(client);
421
+ });
422
+ client.on("close", () => {
423
+ outputSubscribers.delete(client);
424
+ });
425
+ });
426
+ server.listen(injectSockPath);
427
+ return server;
428
+ }
429
+
430
+ function cleanupInjectServer(server) {
431
+ for (const sub of outputSubscribers) {
432
+ try { sub.destroy(); } catch { /* ignore */ }
433
+ }
434
+ outputSubscribers.clear();
435
+ try {
436
+ if (server) server.close();
437
+ if (fs.existsSync(injectSockPath)) fs.unlinkSync(injectSockPath);
438
+ } catch {
439
+ // ignore
440
+ }
147
441
  }
148
442
 
149
443
  function flushPending() {
@@ -157,7 +451,9 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
157
451
 
158
452
  function deliverChunk(chunk) {
159
453
  if (!chunk) return;
160
- const payload = JSON.stringify({ stream: true, delta: chunk });
454
+ const cleaned = sanitizeChunk(chunk);
455
+ if (!cleaned) return;
456
+ const payload = JSON.stringify({ stream: true, delta: cleaned });
161
457
  if (currentPublisher) {
162
458
  enqueueSend(currentPublisher, payload);
163
459
  } else {
@@ -197,9 +493,33 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
197
493
 
198
494
  function attachPty(proc) {
199
495
  proc.onData((data) => {
200
- const clean = stripAnsi(String(data || "")).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
496
+ const raw = String(data || "");
497
+ broadcastOutput(raw);
498
+ // Auto-respond to DSR (Device Status Report) cursor position query.
499
+ // Ink/codex sends \x1b[6n at startup; node-pty doesn't reply automatically,
500
+ // causing codex to crash with "cursor position could not be read".
501
+ if (raw.includes("\x1b[6n") || raw.includes("\x1b[?6n")) {
502
+ proc.write("\x1b[1;1R");
503
+ }
504
+ const clean = stripAnsi(raw).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
201
505
  if (!clean) return;
202
506
  outputBuffer += clean;
507
+ if (suppressEcho) {
508
+ if (echoMarker && outputBuffer.includes(echoMarker)) {
509
+ const idx = outputBuffer.indexOf(echoMarker);
510
+ outputBuffer = outputBuffer.slice(idx + echoMarker.length);
511
+ outputBuffer = outputBuffer.replace(/^\n+/, "");
512
+ suppressEcho = false;
513
+ currentMarker = echoMarker;
514
+ echoMarker = "";
515
+ if (suppressTimer) {
516
+ clearTimeout(suppressTimer);
517
+ suppressTimer = null;
518
+ }
519
+ } else {
520
+ return;
521
+ }
522
+ }
203
523
  if (currentMarker) {
204
524
  const idx = outputBuffer.indexOf(currentMarker);
205
525
  if (idx !== -1) {
@@ -208,6 +528,9 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
208
528
  if (before) {
209
529
  deliverChunk(before);
210
530
  }
531
+ if (currentPublisher) {
532
+ enqueueSend(currentPublisher, JSON.stringify({ stream: true, done: true, reason: "marker" }));
533
+ }
211
534
  currentMarker = "";
212
535
  busy = false;
213
536
  currentPublisher = "";
@@ -224,10 +547,29 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
224
547
  }
225
548
  }
226
549
  scheduleFlush();
550
+ // Ready detection: during TUI startup, reset the quiet timer on each output.
551
+ // Once output stops for READY_QUIET_MS, the TUI is considered initialized.
552
+ if (!ptyReady && !busy) {
553
+ if (readyTimer) clearTimeout(readyTimer);
554
+ readyTimer = setTimeout(() => {
555
+ readyTimer = null;
556
+ if (!ptyReady) {
557
+ ptyReady = true;
558
+ // Discard TUI startup noise accumulated before ready
559
+ outputBuffer = "";
560
+ pendingOutput = [];
561
+ logNote("[internal-pty] TUI ready (output quiet for " + READY_QUIET_MS + "ms)");
562
+ processQueue();
563
+ }
564
+ }, READY_QUIET_MS);
565
+ }
227
566
  if (busy) {
228
567
  if (idleTimer) clearTimeout(idleTimer);
229
568
  idleTimer = setTimeout(() => {
230
569
  idleTimer = null;
570
+ if (currentPublisher) {
571
+ enqueueSend(currentPublisher, JSON.stringify({ stream: true, done: true, reason: "idle" }));
572
+ }
231
573
  busy = false;
232
574
  currentPublisher = "";
233
575
  processQueue();
@@ -236,6 +578,15 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
236
578
  });
237
579
 
238
580
  proc.onExit(({ exitCode, signal }) => {
581
+ // Skip if this process has been replaced (e.g., by restartPty)
582
+ if (proc !== ptyProcess) return;
583
+
584
+ ptyAlive = false;
585
+ ptyReady = false;
586
+ if (readyTimer) {
587
+ clearTimeout(readyTimer);
588
+ readyTimer = null;
589
+ }
239
590
  if (outputBuffer) {
240
591
  flushOutput();
241
592
  }
@@ -247,14 +598,56 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
247
598
  clearTimeout(idleTimer);
248
599
  idleTimer = null;
249
600
  }
601
+ if (watchdogTimer) {
602
+ clearTimeout(watchdogTimer);
603
+ watchdogTimer = null;
604
+ }
250
605
  const note = `[internal-pty] process exited code=${exitCode} signal=${signal || ""}`.trim();
251
606
  if (currentPublisher) enqueueSend(currentPublisher, note);
252
607
  logNote(note);
253
- running = false;
608
+
609
+ // Reset busy state
610
+ busy = false;
611
+ currentPublisher = "";
612
+ currentMarker = "";
613
+
614
+ // If stop() was called, let the runner exit
615
+ if (!running) return;
616
+
617
+ // Auto-restart with backoff
618
+ const elapsed = Date.now() - lastSpawnTime;
619
+ if (elapsed > RESTART_STABLE_MS) {
620
+ restartCount = 0; // Process was stable long enough, reset counter
621
+ }
622
+ restartCount++;
623
+
624
+ if (restartCount <= MAX_RESTARTS) {
625
+ const delay = Math.min(restartCount * RESTART_DELAY_MS, 10000);
626
+ logNote(`Auto-restarting PTY in ${delay}ms (attempt ${restartCount}/${MAX_RESTARTS})`);
627
+ setTimeout(() => {
628
+ if (!running) return;
629
+ try {
630
+ ptyProcess = spawnPtyProcess();
631
+ processQueue();
632
+ } catch (err) {
633
+ logNote(`Restart failed: ${err.message || err}`);
634
+ void fallbackHeadless(`restart failed: ${err.message || err}`);
635
+ }
636
+ }, delay);
637
+ } else {
638
+ logNote(`Max PTY restarts (${MAX_RESTARTS}) reached, falling back to headless runner`);
639
+ void fallbackHeadless("max PTY restarts exceeded");
640
+ }
254
641
  });
255
642
  }
256
643
 
257
644
  function spawnPtyProcess() {
645
+ lastSpawnTime = Date.now();
646
+ ptyReady = false;
647
+ if (readyTimer) {
648
+ clearTimeout(readyTimer);
649
+ readyTimer = null;
650
+ }
258
651
  const proc = pty.spawn(command, args, {
259
652
  name: "xterm-256color",
260
653
  cols: 80,
@@ -262,6 +655,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
262
655
  cwd: projectRoot,
263
656
  env,
264
657
  });
658
+ ptyAlive = true;
265
659
  attachPty(proc);
266
660
  return proc;
267
661
  }
@@ -269,11 +663,16 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
269
663
  function restartPty(reason) {
270
664
  if (!running) return;
271
665
  logNote(`Restarting PTY: ${reason}`);
666
+ ptyAlive = false;
667
+ ptyReady = false;
272
668
  if (outputBuffer) {
273
669
  flushOutput();
274
670
  }
671
+ // Clear reference first so the old onExit handler skips (proc !== ptyProcess)
672
+ const oldPty = ptyProcess;
673
+ ptyProcess = null;
275
674
  try {
276
- if (ptyProcess) ptyProcess.kill();
675
+ if (oldPty) oldPty.kill();
277
676
  } catch {
278
677
  // ignore
279
678
  }
@@ -287,6 +686,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
287
686
  if (outputBuffer) {
288
687
  flushOutput();
289
688
  }
689
+ cleanupInjectServer(injectServer);
290
690
  try {
291
691
  if (ptyProcess) ptyProcess.kill();
292
692
  } catch {
@@ -299,6 +699,7 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
299
699
 
300
700
  const stop = () => {
301
701
  running = false;
702
+ cleanupInjectServer(injectServer);
302
703
  try {
303
704
  if (ptyProcess) ptyProcess.kill();
304
705
  } catch {
@@ -308,22 +709,62 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
308
709
 
309
710
  process.on("SIGTERM", stop);
310
711
  process.on("SIGINT", stop);
712
+ // Ignore SIGHUP so terminal closure doesn't kill the ptyRunner
713
+ // while the daemon is still alive.
714
+ process.on("SIGHUP", () => {});
311
715
 
312
716
  ptyProcess = spawnPtyProcess();
313
717
 
314
718
  function processQueue() {
315
- if (busy || messageQueue.length === 0 || !running) return;
719
+ if (busy || messageQueue.length === 0 || !running || !ptyAlive || !ptyReady) return;
316
720
  const next = messageQueue.shift();
317
721
  if (!next) return;
318
722
  busy = true;
319
723
  currentPublisher = next.publisher;
320
724
  currentMarker = next.marker || "";
725
+ if (suppressTimer) {
726
+ clearTimeout(suppressTimer);
727
+ suppressTimer = null;
728
+ }
321
729
  flushPending();
322
730
  if (next.text) {
323
731
  if (next.raw) {
324
732
  ptyProcess.write(next.text);
325
733
  } else {
326
- ptyProcess.write(`${buildPrompt(next.text, currentMarker)}\n`);
734
+ // Write text first, then send Enter separately.
735
+ // Codex Ink TUI requires text and submit key as separate writes.
736
+ // IMPORTANT: Defer marker detection until after Enter is sent,
737
+ // because the prompt echo (TextInput display) contains the marker text.
738
+ const prompt = buildPrompt(next.text, currentMarker);
739
+ const savedMarker = currentMarker;
740
+ suppressEcho = true;
741
+ echoMarker = savedMarker;
742
+ currentMarker = ""; // Disable marker detection during prompt echo & formatted display
743
+ ptyProcess.write(prompt);
744
+ setTimeout(() => {
745
+ if (ptyProcess && ptyAlive) {
746
+ outputBuffer = "";
747
+ // Send ESC first to dismiss any auto-complete/suggestion overlay
748
+ // in Ink-based TUIs (Claude Code, Codex), then CR to submit.
749
+ // This matches the inject socket pattern in launcher.js.
750
+ ptyProcess.write("\x1b");
751
+ setTimeout(() => {
752
+ if (ptyProcess && ptyAlive) {
753
+ ptyProcess.write("\r");
754
+ }
755
+ // Fallback: if we never observe the marker in echoed output,
756
+ // stop suppressing after a short delay to avoid freezing output.
757
+ suppressTimer = setTimeout(() => {
758
+ suppressTimer = null;
759
+ if (!suppressEcho) return;
760
+ suppressEcho = false;
761
+ echoMarker = "";
762
+ currentMarker = savedMarker;
763
+ outputBuffer = "";
764
+ }, 1500);
765
+ }, 100);
766
+ }
767
+ }, 200);
327
768
  }
328
769
  }
329
770
  if (watchdogTimer) clearTimeout(watchdogTimer);
@@ -332,6 +773,9 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
332
773
  if (!busy) return;
333
774
  const timeoutNote = `[internal-pty] marker timeout; action=${watchdogAction}`;
334
775
  if (currentPublisher) enqueueSend(currentPublisher, timeoutNote);
776
+ if (currentPublisher) {
777
+ enqueueSend(currentPublisher, JSON.stringify({ stream: true, done: true, reason: "timeout" }));
778
+ }
335
779
  logNote(timeoutNote);
336
780
  if (watchdogAction === "fallback") {
337
781
  void fallbackHeadless("marker timeout");
@@ -347,7 +791,30 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
347
791
  }, watchdogMs);
348
792
  }
349
793
 
794
+ // Heartbeat to keep agent "online" in bus status
795
+ let lastHeartbeat = 0;
796
+ const HEARTBEAT_INTERVAL = 30000;
797
+ const updateHeartbeat = () => {
798
+ try {
799
+ spawnSync("ufoo", ["bus", "check", subscriber], {
800
+ cwd: projectRoot,
801
+ env: { ...process.env, UFOO_SUBSCRIBER_ID: subscriber },
802
+ stdio: "ignore",
803
+ timeout: 5000,
804
+ });
805
+ } catch {
806
+ // ignore heartbeat errors
807
+ }
808
+ };
809
+
350
810
  while (running) {
811
+ // Periodic heartbeat
812
+ const now = Date.now();
813
+ if (now - lastHeartbeat > HEARTBEAT_INTERVAL) {
814
+ updateHeartbeat();
815
+ lastHeartbeat = now;
816
+ }
817
+
351
818
  const lines = drainQueue(queueFile);
352
819
  if (lines.length > 0) {
353
820
  const events = [];
@@ -365,7 +832,10 @@ async function runPtyRunner({ projectRoot, agentType = "codex" }) {
365
832
  messageQueue.shift();
366
833
  }
367
834
  const marker = raw ? "" : `__UFOO_DONE_${Date.now()}_${Math.random().toString(16).slice(2)}__`;
368
- messageQueue.push({ publisher: evt.publisher || "unknown", raw, text, marker });
835
+ const publisher = typeof evt.publisher === "object" && evt.publisher
836
+ ? (evt.publisher.subscriber || evt.publisher.nickname || "unknown")
837
+ : (evt.publisher || "unknown");
838
+ messageQueue.push({ publisher, raw, text, marker });
369
839
  }
370
840
  }
371
841
  processQueue();