trekoon 0.4.1 → 0.4.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 (58) hide show
  1. package/.agents/skills/trekoon/SKILL.md +97 -765
  2. package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
  3. package/.agents/skills/trekoon/reference/execution.md +188 -159
  4. package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
  5. package/.agents/skills/trekoon/reference/planning.md +213 -213
  6. package/.agents/skills/trekoon/reference/status-machine.md +21 -0
  7. package/.agents/skills/trekoon/reference/sync.md +82 -0
  8. package/README.md +29 -8
  9. package/docs/ai-agents.md +65 -6
  10. package/docs/commands.md +149 -5
  11. package/docs/machine-contracts.md +123 -0
  12. package/docs/quickstart.md +55 -3
  13. package/package.json +1 -1
  14. package/src/board/assets/app.js +47 -13
  15. package/src/board/assets/components/Component.js +20 -8
  16. package/src/board/assets/components/Workspace.js +9 -3
  17. package/src/board/assets/components/helpers.js +4 -0
  18. package/src/board/assets/runtime/delegation.js +8 -0
  19. package/src/board/assets/runtime/focus-trap.js +48 -0
  20. package/src/board/assets/state/actions.js +45 -4
  21. package/src/board/assets/state/api.js +304 -17
  22. package/src/board/assets/state/store.js +82 -11
  23. package/src/board/assets/state/url.js +10 -0
  24. package/src/board/assets/state/utils.js +2 -1
  25. package/src/board/event-bus.ts +81 -0
  26. package/src/board/routes.ts +430 -40
  27. package/src/board/server.ts +86 -10
  28. package/src/board/snapshot.ts +6 -0
  29. package/src/board/wal-watcher.ts +313 -0
  30. package/src/commands/board.ts +52 -17
  31. package/src/commands/epic.ts +7 -9
  32. package/src/commands/error-utils.ts +54 -1
  33. package/src/commands/help.ts +75 -10
  34. package/src/commands/migrate.ts +153 -24
  35. package/src/commands/quickstart.ts +7 -0
  36. package/src/commands/skills.ts +17 -5
  37. package/src/commands/subtask.ts +71 -10
  38. package/src/commands/suggest.ts +6 -13
  39. package/src/commands/task.ts +137 -88
  40. package/src/domain/batch-validation.ts +329 -0
  41. package/src/domain/cascade-planner.ts +412 -0
  42. package/src/domain/dependency-rules.ts +15 -0
  43. package/src/domain/mutation-service.ts +842 -187
  44. package/src/domain/search.ts +113 -0
  45. package/src/domain/tracker-domain.ts +167 -693
  46. package/src/domain/types.ts +56 -2
  47. package/src/export/render-markdown.ts +1 -2
  48. package/src/index.ts +37 -0
  49. package/src/runtime/cli-shell.ts +44 -0
  50. package/src/runtime/daemon.ts +700 -0
  51. package/src/storage/backup.ts +166 -0
  52. package/src/storage/database.ts +268 -4
  53. package/src/storage/migrations.ts +441 -22
  54. package/src/storage/path.ts +8 -0
  55. package/src/storage/schema.ts +5 -1
  56. package/src/sync/event-writes.ts +38 -11
  57. package/src/sync/git-context.ts +226 -8
  58. package/src/sync/service.ts +679 -156
@@ -0,0 +1,700 @@
1
+ import { chmodSync, existsSync, mkdirSync, realpathSync, unlinkSync, statSync } from "node:fs";
2
+ import { connect, createServer, type Server, type Socket } from "node:net";
3
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
4
+ import { redactStack, safeErrorMessage } from "../commands/error-utils";
5
+ import { closeCachedDatabases } from "../storage/database";
6
+ import { resolveStoragePaths } from "../storage/path";
7
+
8
+ import { executeShell, parseInvocation, renderShellResult } from "./cli-shell";
9
+
10
+ /**
11
+ * Daemon spike (experimental).
12
+ *
13
+ * Runs the trekoon CLI inside a long-lived Bun process listening on a Unix
14
+ * domain socket. Clients submit `{argv, cwd}` payloads; the server runs
15
+ * the same `executeShell` pipeline as the one-shot CLI and returns
16
+ * `{stdout, stderr, exitCode}`.
17
+ *
18
+ * Environment variables are intentionally NOT part of the request contract.
19
+ * The daemon process owns its own environment (set at `trekoon serve`
20
+ * startup). Forwarding the client environment would (a) leak secrets across
21
+ * the socket, (b) require the server to apply them, which it does not, and
22
+ * (c) muddy the equivalence claim with the one-shot CLI. Per-call envs are
23
+ * not supported — narrow the equivalence claim instead.
24
+ *
25
+ * Status: NOT the default path. Activated via `TREKOON_DAEMON=1` or the
26
+ * `--daemon` flag. The one-shot CLI behavior is unchanged when the daemon is
27
+ * unused or unreachable.
28
+ */
29
+
30
+ export interface DaemonRequest {
31
+ readonly argv: readonly string[];
32
+ readonly cwd: string;
33
+ }
34
+
35
+ export interface DaemonResponse {
36
+ readonly stdout: string;
37
+ readonly stderr: string;
38
+ readonly exitCode: number;
39
+ }
40
+
41
+ const REQUEST_TERMINATOR = "\n";
42
+ const MAX_REQUEST_BYTES = 1_000_000;
43
+ /** Maximum bytes buffered across all open server sockets at once. */
44
+ const MAX_TOTAL_BUFFERED_BYTES = 8 * MAX_REQUEST_BYTES;
45
+ /** Default cap on concurrent open server-side sockets. */
46
+ const DEFAULT_MAX_CONNECTIONS = 32;
47
+ /** Idle/incomplete-request timeout for a server-side socket. */
48
+ const SERVER_SOCKET_IDLE_MS = 5_000;
49
+ const OWNER_ONLY_MASK = 0o077;
50
+
51
+ /**
52
+ * Pre-write transport failure: the daemon socket was unreachable or the
53
+ * request never made it onto the wire. Safe to fall back to in-process
54
+ * dispatch — no server-side mutation could have run.
55
+ */
56
+ export class PreWriteTransportError extends Error {
57
+ public override readonly cause: unknown;
58
+ public constructor(message: string, cause?: unknown) {
59
+ super(message);
60
+ this.name = "PreWriteTransportError";
61
+ this.cause = cause;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Post-write transport failure: the request bytes were already flushed to
67
+ * the daemon socket when the failure occurred. The server may have
68
+ * committed the mutation. The CLI must NOT silently re-run the command in
69
+ * process — exit non-zero so the caller can decide.
70
+ */
71
+ export class PostWriteError extends Error {
72
+ public override readonly cause: unknown;
73
+ public constructor(message: string, cause?: unknown) {
74
+ super(message);
75
+ this.name = "PostWriteError";
76
+ this.cause = cause;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Resolve the canonical Unix socket path for the given working directory.
82
+ * The socket lives next to the SQLite database under `.trekoon/`.
83
+ */
84
+ export function resolveDaemonSocketPath(cwd: string = process.cwd()): string {
85
+ const paths = resolveStoragePaths(cwd);
86
+ return `${paths.storageDir}/daemon.sock`;
87
+ }
88
+
89
+ function safeUnlink(path: string): void {
90
+ try {
91
+ unlinkSync(path);
92
+ } catch (error: unknown) {
93
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
94
+ throw error;
95
+ }
96
+ }
97
+ }
98
+
99
+ function isPreWriteTransportCode(code: string | undefined): boolean {
100
+ return (
101
+ code === "ENOENT"
102
+ || code === "ECONNREFUSED"
103
+ || code === "EACCES"
104
+ || code === "EPERM"
105
+ || code === "ETIMEDOUT"
106
+ );
107
+ }
108
+
109
+ function debugLog(prefix: string, payload: unknown): void {
110
+ if (process.env.TREKOON_DEBUG === "1") {
111
+ // eslint-disable-next-line no-console
112
+ console.error(prefix, payload);
113
+ return;
114
+ }
115
+ if (payload instanceof Error) {
116
+ const message: string = safeErrorMessage(payload, "unknown error");
117
+ // eslint-disable-next-line no-console
118
+ console.error(`${prefix} ${message}`);
119
+ return;
120
+ }
121
+ // eslint-disable-next-line no-console
122
+ console.error(prefix);
123
+ }
124
+
125
+ function formatMode(mode: number): string {
126
+ // eslint-disable-next-line no-bitwise
127
+ return `0o${(mode & 0o777).toString(8).padStart(3, "0")}`;
128
+ }
129
+
130
+ function assertOwnerOnlyMode(path: string, label: string): void {
131
+ const stats = statSync(path);
132
+ // eslint-disable-next-line no-bitwise
133
+ if ((stats.mode & OWNER_ONLY_MASK) !== 0) {
134
+ throw new Error(`${label} at ${path} must be owner-only; got ${formatMode(stats.mode)}`);
135
+ }
136
+ }
137
+
138
+ export function __assertOwnerOnlyModeForTests(path: string, label: string): void {
139
+ assertOwnerOnlyMode(path, label);
140
+ }
141
+
142
+ function isPathWithin(candidatePath: string, rootPath: string): boolean {
143
+ const candidate: string = realpathSync(resolve(candidatePath));
144
+ const root: string = realpathSync(resolve(rootPath));
145
+ const pathToCandidate: string = relative(root, candidate);
146
+ return pathToCandidate === "" || (!pathToCandidate.startsWith("..") && !isAbsolute(pathToCandidate));
147
+ }
148
+
149
+ function isAllowedRequestCwd(cwd: string, allowedRoots: readonly string[]): boolean {
150
+ return allowedRoots.some((root: string): boolean => isPathWithin(cwd, root));
151
+ }
152
+
153
+ /**
154
+ * Execute a single daemon request through the in-process CLI shell.
155
+ * Exported for direct unit testing without a socket round-trip.
156
+ */
157
+ export async function executeDaemonRequest(request: DaemonRequest): Promise<DaemonResponse> {
158
+ const argv: readonly string[] = request.argv;
159
+ const cwd: string = request.cwd;
160
+ const parsed = parseInvocation(argv, { stdoutIsTTY: false });
161
+
162
+ try {
163
+ const result = await executeShell(parsed, cwd);
164
+ const rendered: string = renderShellResult(result, parsed.mode, parsed.compatibilityMode, {
165
+ compact: parsed.compact,
166
+ });
167
+
168
+ if (result.ok) {
169
+ return {
170
+ stdout: `${rendered}\n`,
171
+ stderr: "",
172
+ exitCode: 0,
173
+ };
174
+ }
175
+
176
+ return {
177
+ stdout: "",
178
+ stderr: `${rendered}\n`,
179
+ exitCode: 1,
180
+ };
181
+ } catch (error: unknown) {
182
+ // Never include the stack in the response envelope: stacks contain
183
+ // absolute filesystem paths and may carry secret-bearing error text.
184
+ // The stack is still surfaced locally on the daemon's stderr for
185
+ // operator-side debugging, with secrets redacted unless TREKOON_DEBUG=1.
186
+ if (error instanceof Error && typeof error.stack === "string") {
187
+ const stack: string = process.env.TREKOON_DEBUG === "1"
188
+ ? error.stack
189
+ : redactStack(error.stack);
190
+ // eslint-disable-next-line no-console
191
+ console.error("[trekoon daemon] dispatch failure:", stack);
192
+ } else {
193
+ // eslint-disable-next-line no-console
194
+ console.error("[trekoon daemon] dispatch failure:", error);
195
+ }
196
+ const sanitized: string = safeErrorMessage(error, "unknown error");
197
+ return {
198
+ stdout: "",
199
+ stderr: `Daemon dispatch failure: ${sanitized}\n`,
200
+ exitCode: 1,
201
+ };
202
+ }
203
+ }
204
+
205
+ export interface DaemonServerHandle {
206
+ readonly socketPath: string;
207
+ readonly server: Server;
208
+ close(): Promise<void>;
209
+ }
210
+
211
+ export interface StartDaemonOptions {
212
+ readonly socketPath?: string;
213
+ readonly cwd?: string;
214
+ /** Suppress stdout banner; used by tests. */
215
+ readonly silent?: boolean;
216
+ /** Override the default concurrent connection cap. */
217
+ readonly maxConnections?: number;
218
+ }
219
+
220
+ /**
221
+ * Start the daemon server. Binds the Unix socket with mode 0o600 and ensures
222
+ * the parent directory has mode 0o700. Returns a handle for graceful
223
+ * shutdown. The CLI dispatch is run via `executeDaemonRequest`.
224
+ */
225
+ export async function startDaemonServer(options: StartDaemonOptions = {}): Promise<DaemonServerHandle> {
226
+ // Flip the in-process DB cache flag so subsequent openTrekoonDatabase calls
227
+ // reuse a held-open connection. The default one-shot CLI never sets this.
228
+ process.env.TREKOON_DAEMON_INPROCESS = "1";
229
+
230
+ const cwd: string = options.cwd ?? process.cwd();
231
+ const socketPath: string = options.socketPath ?? resolveDaemonSocketPath(cwd);
232
+ const socketDir: string = dirname(socketPath);
233
+ const maxConnections: number = options.maxConnections ?? DEFAULT_MAX_CONNECTIONS;
234
+ const daemonStoragePaths = resolveStoragePaths(cwd);
235
+ const allowedRequestRoots: readonly string[] = [
236
+ daemonStoragePaths.worktreeRoot,
237
+ daemonStoragePaths.storageDir,
238
+ ];
239
+
240
+ if (!existsSync(socketDir)) {
241
+ mkdirSync(socketDir, { recursive: true, mode: 0o700 });
242
+ }
243
+ // Tighten parent dir perms even if it pre-existed.
244
+ chmodSync(socketDir, 0o700);
245
+ assertOwnerOnlyMode(socketDir, "daemon socket directory");
246
+
247
+ // Stale socket from a previous crashed run.
248
+ safeUnlink(socketPath);
249
+
250
+ // Tracks open server-side sockets and the bytes each one currently has
251
+ // buffered. Used to enforce both the per-server connection cap and a
252
+ // global upper bound on memory pressure across all in-flight requests.
253
+ const liveSockets: Set<Socket> = new Set<Socket>();
254
+ const processingSockets: Set<Socket> = new Set<Socket>();
255
+ const inFlightRequests: Set<Promise<void>> = new Set<Promise<void>>();
256
+ let totalBufferedBytes = 0;
257
+
258
+ const server: Server = createServer((socket: Socket): void => {
259
+ if (liveSockets.size >= maxConnections) {
260
+ const busyEnvelope: string = `${JSON.stringify({
261
+ stdout: "",
262
+ stderr: "Daemon: daemon_busy (too many concurrent connections)\n",
263
+ exitCode: 1,
264
+ })}\n`;
265
+ socket.write(busyEnvelope, (): void => {
266
+ socket.end();
267
+ });
268
+ return;
269
+ }
270
+
271
+ liveSockets.add(socket);
272
+ let buffer = "";
273
+ let aborted = false;
274
+ let perSocketBytes = 0;
275
+
276
+ socket.setEncoding("utf8");
277
+ // Reject sockets that connect, never send a terminator, and sit idle —
278
+ // these accumulate file descriptors and buffer memory otherwise.
279
+ socket.setTimeout(SERVER_SOCKET_IDLE_MS);
280
+
281
+ const releaseBuffered = (): void => {
282
+ totalBufferedBytes -= perSocketBytes;
283
+ perSocketBytes = 0;
284
+ };
285
+
286
+ const onClose = (): void => {
287
+ liveSockets.delete(socket);
288
+ releaseBuffered();
289
+ };
290
+
291
+ socket.on("timeout", (): void => {
292
+ if (aborted) {
293
+ return;
294
+ }
295
+ aborted = true;
296
+ try {
297
+ socket.write(
298
+ `${JSON.stringify({
299
+ stdout: "",
300
+ stderr: "Daemon: incomplete request (socket idle timeout)\n",
301
+ exitCode: 1,
302
+ })}\n`,
303
+ );
304
+ } catch {
305
+ // best effort
306
+ }
307
+ socket.end();
308
+ socket.destroy();
309
+ });
310
+
311
+ socket.on("data", (chunk: string): void => {
312
+ if (aborted) {
313
+ return;
314
+ }
315
+ const chunkBytes: number = Buffer.byteLength(chunk, "utf8");
316
+ buffer += chunk;
317
+ perSocketBytes += chunkBytes;
318
+ totalBufferedBytes += chunkBytes;
319
+
320
+ if (buffer.length > MAX_REQUEST_BYTES || totalBufferedBytes > MAX_TOTAL_BUFFERED_BYTES) {
321
+ aborted = true;
322
+ const reason: string = totalBufferedBytes > MAX_TOTAL_BUFFERED_BYTES
323
+ ? "daemon_busy (server buffer pressure)"
324
+ : "request exceeded max bytes";
325
+ try {
326
+ socket.write(
327
+ `${JSON.stringify({
328
+ stdout: "",
329
+ stderr: `Daemon: ${reason}\n`,
330
+ exitCode: 1,
331
+ })}\n`,
332
+ );
333
+ } catch {
334
+ // best effort
335
+ }
336
+ socket.end();
337
+ return;
338
+ }
339
+
340
+ const terminatorIndex: number = buffer.indexOf(REQUEST_TERMINATOR);
341
+ if (terminatorIndex < 0) {
342
+ return;
343
+ }
344
+
345
+ aborted = true;
346
+ const payload: string = buffer.slice(0, terminatorIndex);
347
+ buffer = "";
348
+ releaseBuffered();
349
+ // Once we have a complete request the idle timer is no longer
350
+ // meaningful; the dispatcher controls the lifetime from here.
351
+ socket.setTimeout(0);
352
+
353
+ processingSockets.add(socket);
354
+ const requestPromise = handlePayload(payload, socket, allowedRequestRoots).finally((): void => {
355
+ processingSockets.delete(socket);
356
+ inFlightRequests.delete(requestPromise);
357
+ });
358
+ inFlightRequests.add(requestPromise);
359
+ });
360
+
361
+ socket.on("error", (error: Error): void => {
362
+ // Surface as a debug-level redacted log; never as a bare empty handler.
363
+ debugLog("[trekoon daemon] socket error:", error);
364
+ });
365
+
366
+ socket.on("close", onClose);
367
+ });
368
+
369
+ await new Promise<void>((resolve, reject): void => {
370
+ const onError = (error: Error): void => {
371
+ server.removeListener("listening", onListening);
372
+ reject(error);
373
+ };
374
+ const onListening = (): void => {
375
+ server.removeListener("error", onError);
376
+ resolve();
377
+ };
378
+ server.once("error", onError);
379
+ server.once("listening", onListening);
380
+ server.listen(socketPath);
381
+ });
382
+
383
+ // Owner-only mode is enforced by the pre-created 0o700 parent directory
384
+ // plus this post-listen chmod. We deliberately do NOT wrap server.listen
385
+ // in a process.umask() override: that mutates global process state for
386
+ // every concurrent operation and was the source of an audit-flagged
387
+ // race when other code allocates fds during startup.
388
+ try {
389
+ chmodSync(socketPath, 0o600);
390
+ assertOwnerOnlyMode(socketPath, "daemon socket");
391
+ } catch (error: unknown) {
392
+ await new Promise<void>((resolve): void => {
393
+ server.close((): void => resolve());
394
+ });
395
+ safeUnlink(socketPath);
396
+ throw error;
397
+ }
398
+
399
+ if (!options.silent) {
400
+ process.stdout.write(`trekoon daemon listening on ${socketPath}\n`);
401
+ }
402
+
403
+ const handle: DaemonServerHandle = {
404
+ socketPath,
405
+ server,
406
+ close: async (): Promise<void> => {
407
+ const serverClosed = new Promise<void>((resolve): void => {
408
+ server.close((): void => resolve());
409
+ });
410
+ // Force-close idle sockets so server.close() resolves promptly during
411
+ // test teardown, but let in-flight dispatches finish before DB shutdown.
412
+ for (const sock of liveSockets) {
413
+ if (processingSockets.has(sock)) {
414
+ continue;
415
+ }
416
+ try {
417
+ sock.destroy();
418
+ } catch {
419
+ // best effort
420
+ }
421
+ }
422
+ await Promise.allSettled([...inFlightRequests]);
423
+ for (const sock of liveSockets) {
424
+ try {
425
+ sock.destroy();
426
+ } catch {
427
+ // best effort
428
+ }
429
+ }
430
+ await serverClosed;
431
+ liveSockets.clear();
432
+ processingSockets.clear();
433
+ totalBufferedBytes = 0;
434
+ safeUnlink(socketPath);
435
+ closeCachedDatabases();
436
+ delete process.env.TREKOON_DAEMON_INPROCESS;
437
+ },
438
+ };
439
+
440
+ return handle;
441
+ }
442
+
443
+ async function handlePayload(
444
+ payload: string,
445
+ socket: Socket,
446
+ allowedRequestRoots: readonly string[],
447
+ ): Promise<void> {
448
+ let response: DaemonResponse;
449
+ try {
450
+ const parsed: unknown = JSON.parse(payload);
451
+ if (!isDaemonRequest(parsed)) {
452
+ response = {
453
+ stdout: "",
454
+ stderr: "Daemon: invalid request payload\n",
455
+ exitCode: 1,
456
+ };
457
+ } else if (!isAllowedRequestCwd(parsed.cwd, allowedRequestRoots)) {
458
+ response = {
459
+ stdout: "",
460
+ stderr: "Daemon: request cwd is outside the daemon worktree/storage scope\n",
461
+ exitCode: 1,
462
+ };
463
+ } else {
464
+ response = await executeDaemonRequest(parsed);
465
+ }
466
+ } catch (error: unknown) {
467
+ // Sanitize and never include a stack — the wire envelope must not leak
468
+ // filesystem paths or secret-bearing error text. The stack stays on the
469
+ // daemon's local stderr for operator debugging, redacted unless
470
+ // TREKOON_DEBUG=1 explicitly opts in to raw output.
471
+ if (error instanceof Error && typeof error.stack === "string") {
472
+ const stack: string = process.env.TREKOON_DEBUG === "1"
473
+ ? error.stack
474
+ : redactStack(error.stack);
475
+ // eslint-disable-next-line no-console
476
+ console.error("[trekoon daemon] payload parse error:", stack);
477
+ }
478
+ const sanitized: string = safeErrorMessage(error, "invalid payload");
479
+ response = {
480
+ stdout: "",
481
+ stderr: `Daemon: payload parse error: ${sanitized}\n`,
482
+ exitCode: 1,
483
+ };
484
+ }
485
+
486
+ const serialized: string = `${JSON.stringify(response)}\n`;
487
+ socket.write(serialized, (): void => {
488
+ socket.end();
489
+ });
490
+ }
491
+
492
+ function isDaemonRequest(value: unknown): value is DaemonRequest {
493
+ if (!value || typeof value !== "object") {
494
+ return false;
495
+ }
496
+ const candidate = value as Record<string, unknown>;
497
+ if (!Array.isArray(candidate.argv)) {
498
+ return false;
499
+ }
500
+ if (!candidate.argv.every((entry): boolean => typeof entry === "string")) {
501
+ return false;
502
+ }
503
+ if (typeof candidate.cwd !== "string") {
504
+ return false;
505
+ }
506
+ // env is no longer part of the request contract; ignore it if present from
507
+ // older clients rather than failing the request.
508
+ return true;
509
+ }
510
+
511
+ export interface DaemonClientResult extends DaemonResponse {
512
+ readonly transport: "daemon";
513
+ }
514
+
515
+ /**
516
+ * Send a single request to a running daemon. Resolves with the parsed
517
+ * response. Throws `PreWriteTransportError` for connect-time failures (the
518
+ * caller may safely fall back to in-process dispatch) and `PostWriteError`
519
+ * once the request bytes have been flushed (the caller MUST surface the
520
+ * failure rather than retrying — the daemon may have already committed).
521
+ */
522
+ export async function sendDaemonRequest(
523
+ socketPath: string,
524
+ request: DaemonRequest,
525
+ timeoutMs: number = 30_000,
526
+ ): Promise<DaemonClientResult> {
527
+ return new Promise<DaemonClientResult>((resolve, reject): void => {
528
+ const socket: Socket = connect(socketPath);
529
+ let buffer = "";
530
+ let settled = false;
531
+ // Flips to true the moment the request bytes are flushed to the kernel
532
+ // socket buffer. Any subsequent error/timeout MUST surface as
533
+ // PostWriteError because the daemon may have already executed the
534
+ // mutation.
535
+ let writeAttempted = false;
536
+
537
+ const fail = (error: unknown): void => {
538
+ if (settled) {
539
+ return;
540
+ }
541
+ settled = true;
542
+ clearTimeout(timer);
543
+ try {
544
+ socket.destroy();
545
+ } catch {
546
+ // best effort
547
+ }
548
+ if (writeAttempted) {
549
+ const cause: unknown = error;
550
+ const message: string = error instanceof Error ? error.message : String(error);
551
+ reject(new PostWriteError(`daemon may have committed; do not retry: ${message}`, cause));
552
+ return;
553
+ }
554
+ if (error instanceof Error) {
555
+ const code: string | undefined = (error as NodeJS.ErrnoException).code;
556
+ if (isPreWriteTransportCode(code)) {
557
+ reject(new PreWriteTransportError(error.message, error));
558
+ return;
559
+ }
560
+ reject(new PreWriteTransportError(error.message, error));
561
+ return;
562
+ }
563
+ reject(new PreWriteTransportError(String(error), error));
564
+ };
565
+
566
+ const timer = setTimeout((): void => {
567
+ if (settled) {
568
+ return;
569
+ }
570
+ // Build the timeout error before we settle so the post/pre-write
571
+ // classifier picks up the current `postWrite` flag.
572
+ const timeoutError = new Error(`daemon request timed out after ${timeoutMs}ms`);
573
+ fail(timeoutError);
574
+ }, timeoutMs);
575
+
576
+ socket.setEncoding("utf8");
577
+
578
+ socket.on("connect", (): void => {
579
+ const wireBytes: string = `${JSON.stringify(request)}${REQUEST_TERMINATOR}`;
580
+ writeAttempted = true;
581
+ socket.write(wireBytes, (writeError?: Error | null): void => {
582
+ if (writeError) {
583
+ fail(writeError);
584
+ return;
585
+ }
586
+ // Callback confirmation is intentionally retained as a second signal
587
+ // for future diagnostics; classification flips synchronously before
588
+ // socket.write can return so write-then-error races are post-write.
589
+ writeAttempted = true;
590
+ });
591
+ });
592
+
593
+ socket.on("data", (chunk: string): void => {
594
+ buffer += chunk;
595
+ });
596
+
597
+ socket.on("error", (error: Error): void => {
598
+ fail(error);
599
+ });
600
+
601
+ socket.on("end", (): void => {
602
+ if (settled) {
603
+ return;
604
+ }
605
+ settled = true;
606
+ clearTimeout(timer);
607
+ try {
608
+ const trimmed: string = buffer.trim();
609
+ const parsed: unknown = JSON.parse(trimmed);
610
+ if (!isDaemonResponse(parsed)) {
611
+ // We did receive a (malformed) response, so the request was
612
+ // post-write — surface as PostWriteError so the CLI does not
613
+ // silently re-run.
614
+ reject(new PostWriteError("daemon returned malformed response"));
615
+ return;
616
+ }
617
+ resolve({ ...parsed, transport: "daemon" });
618
+ } catch (error: unknown) {
619
+ const message: string = error instanceof Error ? error.message : String(error);
620
+ reject(new PostWriteError(`daemon response parse error: ${message}`, error));
621
+ }
622
+ });
623
+ });
624
+ }
625
+
626
+ function isDaemonResponse(value: unknown): value is DaemonResponse {
627
+ if (!value || typeof value !== "object") {
628
+ return false;
629
+ }
630
+ const candidate = value as Record<string, unknown>;
631
+ return (
632
+ typeof candidate.stdout === "string"
633
+ && typeof candidate.stderr === "string"
634
+ && typeof candidate.exitCode === "number"
635
+ );
636
+ }
637
+
638
+ /**
639
+ * Heuristic check that a daemon socket is live: file exists, looks like a
640
+ * socket, and its directory is on the local filesystem. We do NOT round-trip
641
+ * a ping here — the client retries the real request and falls back on error.
642
+ */
643
+ export function isDaemonSocketPresent(socketPath: string): boolean {
644
+ try {
645
+ const stats = statSync(socketPath);
646
+ return stats.isSocket();
647
+ } catch {
648
+ return false;
649
+ }
650
+ }
651
+
652
+ /**
653
+ * Run the daemon in the foreground until SIGINT/SIGTERM. Used by
654
+ * `trekoon serve`.
655
+ */
656
+ export async function runDaemonForeground(options: StartDaemonOptions = {}): Promise<void> {
657
+ const handle = await startDaemonServer(options);
658
+
659
+ if (!options.silent) {
660
+ process.stdout.write("Press Ctrl-C to stop. (experimental spike)\n");
661
+ }
662
+
663
+ await new Promise<void>((resolve): void => {
664
+ const shutdown = (): void => {
665
+ void handle.close().then((): void => {
666
+ resolve();
667
+ });
668
+ };
669
+ process.once("SIGINT", shutdown);
670
+ process.once("SIGTERM", shutdown);
671
+ });
672
+ }
673
+
674
+ /**
675
+ * Try to dispatch the invocation through the daemon. Returns the rendered
676
+ * response on success, or `null` ONLY when the failure happened pre-write
677
+ * (no bytes left this process). Post-write failures are rethrown as
678
+ * `PostWriteError` so the caller can exit non-zero rather than
679
+ * re-executing in-process — the daemon may have committed.
680
+ */
681
+ export async function tryDaemonDispatch(argv: readonly string[]): Promise<DaemonClientResult | null> {
682
+ const cwd: string = process.cwd();
683
+ const socketPath: string = resolveDaemonSocketPath(cwd);
684
+ if (!isDaemonSocketPresent(socketPath)) {
685
+ return null;
686
+ }
687
+
688
+ try {
689
+ // The daemon owns its own environment (set at `trekoon serve` startup);
690
+ // client env is deliberately NOT forwarded over the socket.
691
+ return await sendDaemonRequest(socketPath, { argv: [...argv], cwd });
692
+ } catch (error: unknown) {
693
+ if (error instanceof PreWriteTransportError) {
694
+ return null;
695
+ }
696
+ // PostWriteError or any unclassified failure must surface so src/index.ts
697
+ // can refuse to silently re-run the command.
698
+ throw error;
699
+ }
700
+ }