trekoon 0.4.2 → 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.
@@ -400,6 +400,9 @@ export function createStore(initialSnapshot, options = {}) {
400
400
 
401
401
  const boardStateMemo = createBoardStateMemo(deriveBoardState);
402
402
 
403
+ // Direct `model.store` mutations must manually invalidate this memo before
404
+ // rerendering. Current direct-mutation fields: snapshot, notice, isMutating,
405
+ // dragFeedback.
403
406
  function notify() {
404
407
  boardStateMemo.invalidate();
405
408
  for (const listener of listeners) {
@@ -18,7 +18,9 @@ export type BoardEventListener = (event: BoardEvent) => void;
18
18
 
19
19
  export interface BoardEventBus {
20
20
  publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent;
21
+ markInProcessWrite(timestamp?: number): void;
21
22
  subscribe(listener: BoardEventListener): () => void;
23
+ readonly lastInProcessWriteAt: number;
22
24
  readonly subscriberCount: number;
23
25
  close(): void;
24
26
  }
@@ -27,6 +29,7 @@ export function createBoardEventBus(): BoardEventBus {
27
29
  const listeners = new Set<BoardEventListener>();
28
30
  let nextId = 1;
29
31
  let closed = false;
32
+ let lastInProcessWriteAt = 0;
30
33
 
31
34
  return {
32
35
  publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent {
@@ -51,6 +54,9 @@ export function createBoardEventBus(): BoardEventBus {
51
54
 
52
55
  return event;
53
56
  },
57
+ markInProcessWrite(timestamp = Date.now()): void {
58
+ lastInProcessWriteAt = timestamp;
59
+ },
54
60
  subscribe(listener: BoardEventListener): () => void {
55
61
  if (closed) {
56
62
  return () => {};
@@ -64,6 +70,9 @@ export function createBoardEventBus(): BoardEventBus {
64
70
  get subscriberCount(): number {
65
71
  return listeners.size;
66
72
  },
73
+ get lastInProcessWriteAt(): number {
74
+ return lastInProcessWriteAt;
75
+ },
67
76
  close(): void {
68
77
  closed = true;
69
78
  listeners.clear();
@@ -84,7 +84,7 @@ function readCookieToken(request: Request): string | null {
84
84
  return null;
85
85
  }
86
86
 
87
- function extractToken(request: Request, url: URL): string | null {
87
+ function extractHeaderOrCookieToken(request: Request): string | null {
88
88
  const authorization: string | null = request.headers.get("authorization");
89
89
  if (authorization?.startsWith("Bearer ")) {
90
90
  return authorization.slice("Bearer ".length).trim();
@@ -95,11 +95,6 @@ function extractToken(request: Request, url: URL): string | null {
95
95
  return headerToken.trim();
96
96
  }
97
97
 
98
- const queryToken: string | null = url.searchParams.get("token");
99
- if (queryToken && queryToken.trim().length > 0) {
100
- return queryToken.trim();
101
- }
102
-
103
98
  return readCookieToken(request);
104
99
  }
105
100
 
@@ -108,17 +103,34 @@ function isSqliteBusyMessage(message: string): boolean {
108
103
  return normalized.includes("database is locked") || normalized.includes("database schema is locked");
109
104
  }
110
105
 
111
- function redactDetailLeaves(value: unknown): unknown {
106
+ const REDACT_DETAILS_MAX_DEPTH = 16;
107
+
108
+ export function redactDetailLeaves(
109
+ value: unknown,
110
+ seen: WeakSet<object> = new WeakSet<object>(),
111
+ depth = 0,
112
+ ): unknown {
112
113
  if (typeof value === "string") {
113
114
  return redactSensitive(value);
114
115
  }
116
+ if (depth >= REDACT_DETAILS_MAX_DEPTH) {
117
+ return "[MaxDepth]";
118
+ }
115
119
  if (Array.isArray(value)) {
116
- return value.map((item) => redactDetailLeaves(item));
120
+ if (seen.has(value)) {
121
+ return "[Circular]";
122
+ }
123
+ seen.add(value);
124
+ return value.map((item) => redactDetailLeaves(item, seen, depth + 1));
117
125
  }
118
126
  if (value !== null && typeof value === "object") {
127
+ if (seen.has(value)) {
128
+ return "[Circular]";
129
+ }
130
+ seen.add(value);
119
131
  const out: Record<string, unknown> = {};
120
132
  for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
121
- out[key] = redactDetailLeaves(child);
133
+ out[redactSensitive(key)] = redactDetailLeaves(child, seen, depth + 1);
122
134
  }
123
135
  return out;
124
136
  }
@@ -202,6 +214,7 @@ function publishSnapshotDeltaIfPresent(
202
214
  const delta = readSnapshotDelta(data);
203
215
  if (delta) {
204
216
  eventBus.publishSnapshotDelta(delta);
217
+ eventBus.markInProcessWrite();
205
218
  }
206
219
  }
207
220
 
@@ -212,14 +225,10 @@ function formatSseEvent(eventName: string, data: unknown, id?: number): string {
212
225
 
213
226
  // SSE backpressure thresholds (P1 finding 5).
214
227
  // A client that connects but never reads can otherwise grow the per-stream
215
- // queue without bound every snapshotDelta the bus publishes is encoded
216
- // and pushed, retaining the bytes in `controller`'s internal queue. We
217
- // guard against OOM by tracking unflushed bytes and the time since the
218
- // consumer last pulled, and tearing the connection down once either limit
219
- // is exceeded.
220
- const SSE_MAX_QUEUED_BYTES = 1_000_000; // 1 MB hard cap → drop connection.
221
- const SSE_COALESCE_BYTES = 256_000; // 256 KB soft cap → coalesce deltas.
222
- const SSE_STALL_MS = 30_000; // 30 s without consumption → drop.
228
+ // queue without bound. The stream controller already exposes queue pressure
229
+ // through `desiredSize`; once it goes negative we stop enqueueing sparse
230
+ // deltas and retain one pending full-snapshot frame for the next drain.
231
+ const SSE_STALL_MS = 30_000; // 30 s without consumption under pressure → drop.
223
232
  const SSE_BACKPRESSURE_CHECK_MS = 1_000;
224
233
 
225
234
  function openSnapshotStream(
@@ -243,19 +252,16 @@ function openSnapshotStream(
243
252
  let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
244
253
  let backpressureTimer: ReturnType<typeof setInterval> | null = null;
245
254
 
246
- // Bytes enqueued but not yet consumed via `pull`. Each enqueue adds the
247
- // chunk's byte length; each `pull` decrements by the next pending chunk's
248
- // size (FIFO; we don't peek into the controller's internal queue).
249
- let queuedBytes = 0;
250
- const pendingChunkSizes: number[] = [];
251
255
  let lastConsumeAt = Date.now();
252
256
  let closed = false;
253
- // When over the soft threshold we coalesce snapshotDeltas: only the latest
254
- // is retained, dropping superseded deltas. The cached delta is flushed
255
- // once the queue drains below the soft limit on the next `pull`.
256
- let pendingDelta: { id: number; snapshotDelta: Record<string, unknown> } | null = null;
257
+ let pendingFrame: string | null = null;
258
+ let onAbort: (() => void) | null = null;
257
259
 
258
260
  const cleanupTimers = (): void => {
261
+ if (onAbort) {
262
+ request.signal.removeEventListener("abort", onAbort);
263
+ onAbort = null;
264
+ }
259
265
  if (unsubscribe) {
260
266
  unsubscribe();
261
267
  unsubscribe = null;
@@ -277,22 +283,29 @@ function openSnapshotStream(
277
283
  return;
278
284
  }
279
285
  try {
280
- const bytes = encoder.encode(chunk);
281
- controller.enqueue(bytes);
282
- queuedBytes += bytes.byteLength;
283
- pendingChunkSizes.push(bytes.byteLength);
286
+ controller.enqueue(encoder.encode(chunk));
284
287
  } catch {
285
288
  // Controller closed; cleanup happens via cancel.
286
289
  }
287
290
  };
288
291
 
289
- const flushPendingDelta = (): void => {
290
- if (!pendingDelta || closed) {
292
+ const isBackpressured = (): boolean => (controller.desiredSize ?? 1) < 0;
293
+
294
+ const queueFullSnapshot = (id: number): void => {
295
+ // Sparse per-mutation deltas are not cumulative, so merging them can
296
+ // silently drop changes. Under backpressure, retain a fresh full
297
+ // snapshot frame instead; EventSource clients can converge from it
298
+ // without relying on every skipped delta.
299
+ pendingFrame = formatSseEvent("snapshot", { snapshot: buildBoardSnapshot(domain) }, id);
300
+ };
301
+
302
+ const flushPendingFrame = (): void => {
303
+ if (!pendingFrame || closed || isBackpressured()) {
291
304
  return;
292
305
  }
293
- const delta = pendingDelta;
294
- pendingDelta = null;
295
- enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta: delta.snapshotDelta }, delta.id));
306
+ const frame = pendingFrame;
307
+ pendingFrame = null;
308
+ enqueueRaw(frame);
296
309
  };
297
310
 
298
311
  const closeWithError = (reason: string): void => {
@@ -318,22 +331,15 @@ function openSnapshotStream(
318
331
  if (closed) {
319
332
  return;
320
333
  }
321
- if (queuedBytes >= SSE_MAX_QUEUED_BYTES) {
322
- closeWithError("queued bytes exceeded 1MB hard limit");
334
+ if (isBackpressured()) {
335
+ queueFullSnapshot(id);
323
336
  return;
324
337
  }
325
- if (queuedBytes >= SSE_COALESCE_BYTES) {
326
- // Slow consumer: coalesce. The board snapshot is cumulative; the
327
- // newest delta carries the freshest causally-ordered state for
328
- // every entity it touches, so dropping superseded deltas is safe.
329
- pendingDelta = { id, snapshotDelta };
338
+ flushPendingFrame();
339
+ if (isBackpressured()) {
340
+ queueFullSnapshot(id);
330
341
  return;
331
342
  }
332
- // Fast path: flush any coalesced delta first to preserve ordering,
333
- // then enqueue the new one.
334
- if (pendingDelta) {
335
- flushPendingDelta();
336
- }
337
343
  enqueueRaw(formatSseEvent("snapshotDelta", { snapshotDelta }, id));
338
344
  };
339
345
 
@@ -348,27 +354,26 @@ function openSnapshotStream(
348
354
 
349
355
  // Heartbeats keep proxies and stale-connection detectors happy.
350
356
  heartbeatTimer = setInterval(() => {
357
+ if ((controller.desiredSize ?? 1) < 0) {
358
+ return;
359
+ }
351
360
  enqueueRaw(": heartbeat\n\n");
352
361
  }, 15000);
353
362
 
354
363
  // Backpressure watchdog: if the consumer has not pulled within
355
- // SSE_STALL_MS while pending data is sitting in the queue, treat the
356
- // client as dead and drop the connection so we don't pin memory.
364
+ // SSE_STALL_MS while the stream is under pressure, close with an
365
+ // error frame so EventSource reconnects and receives a fresh snapshot.
357
366
  backpressureTimer = setInterval(() => {
358
367
  if (closed) {
359
368
  return;
360
369
  }
361
- if (queuedBytes >= SSE_MAX_QUEUED_BYTES) {
362
- closeWithError("queued bytes exceeded 1MB hard limit");
363
- return;
364
- }
365
- const stalled = queuedBytes > 0 && Date.now() - lastConsumeAt > SSE_STALL_MS;
370
+ const stalled = (controller.desiredSize ?? 1) < 0 && Date.now() - lastConsumeAt > SSE_STALL_MS;
366
371
  if (stalled) {
367
372
  closeWithError(`no consumer pull within ${SSE_STALL_MS}ms`);
368
373
  }
369
374
  }, SSE_BACKPRESSURE_CHECK_MS);
370
375
 
371
- const onAbort = (): void => {
376
+ onAbort = (): void => {
372
377
  if (closed) {
373
378
  return;
374
379
  }
@@ -387,17 +392,17 @@ function openSnapshotStream(
387
392
  }
388
393
  request.signal.addEventListener("abort", onAbort);
389
394
  },
390
- pull(): void {
391
- // A pull means the consumer drained the next chunk from the queue.
392
- const consumed = pendingChunkSizes.shift();
393
- if (typeof consumed === "number") {
394
- queuedBytes = Math.max(0, queuedBytes - consumed);
395
- }
395
+ pull(controller): void {
396
396
  lastConsumeAt = Date.now();
397
- // Outer-scope flush: re-walk via enqueueRaw on the active controller is
398
- // unnecessary because the controller is only valid inside start();
399
- // instead the next snapshotDelta arrival will see queuedBytes below the
400
- // soft limit and flush pendingDelta automatically.
397
+ if (pendingFrame && !closed && (controller.desiredSize ?? 1) >= 0) {
398
+ const frame = pendingFrame;
399
+ pendingFrame = null;
400
+ try {
401
+ controller.enqueue(encoder.encode(frame));
402
+ } catch {
403
+ // Controller closed.
404
+ }
405
+ }
401
406
  },
402
407
  cancel(): void {
403
408
  closed = true;
@@ -565,35 +570,30 @@ function parseIfMatchHeader(request: Request): number | null {
565
570
 
566
571
  // RFC 7232 §3.1 allows a strong ETag (`"<value>"`) or a weak ETag
567
572
  // (`W/"<value>"`). Trekoon does not differentiate strong from weak
568
- // semantics — a millisecond updatedAt is exact either way — so we
573
+ // semantics — the entity version token is exact either way — so we
569
574
  // accept both shapes. The wildcard `*` is intentionally NOT supported:
570
575
  // it would mean "any current representation matches" and would defeat
571
576
  // the whole purpose of the optimistic-concurrency check, so we surface
572
577
  // it as 400 invalid_input below rather than treating it as a no-op.
573
578
  const stripped = raw.trim().replace(/^W\//iu, "");
574
579
  const trimmed = stripped.replace(/^"+|"+$/g, "");
575
- if (trimmed.length === 0) {
576
- return null;
577
- }
578
-
579
- const parsed = Number(trimmed);
580
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
580
+ if (!/^(0|[1-9]\d*)$/u.test(trimmed)) {
581
581
  throw new DomainError({
582
582
  code: "invalid_input",
583
583
  message:
584
- "If-Match header must be an integer updatedAt millisecond timestamp (RFC 7232 strong or W/-prefixed weak ETag); the `*` wildcard is not supported",
584
+ "If-Match header must be a non-negative integer version token (RFC 7232 strong or W/-prefixed weak ETag); the `*` wildcard is not supported",
585
585
  details: { header: "If-Match", value: raw },
586
586
  });
587
587
  }
588
588
 
589
- return parsed;
589
+ return Number(trimmed);
590
590
  }
591
591
 
592
592
  interface PreconditionFailedDetails {
593
593
  readonly entityKind: "epic" | "task" | "subtask";
594
594
  readonly entityId: string;
595
- readonly currentUpdatedAt: number;
596
- readonly providedUpdatedAt: number;
595
+ readonly currentVersion: number;
596
+ readonly providedVersion: number;
597
597
  }
598
598
 
599
599
  function preconditionFailedResponse(details: PreconditionFailedDetails): Response {
@@ -601,12 +601,12 @@ function preconditionFailedResponse(details: PreconditionFailedDetails): Respons
601
601
  ok: false,
602
602
  error: {
603
603
  code: "precondition_failed",
604
- message: "If-Match version does not match current updatedAt",
604
+ message: "If-Match version does not match current version",
605
605
  details: {
606
606
  entityKind: details.entityKind,
607
607
  entityId: details.entityId,
608
- currentUpdatedAt: details.currentUpdatedAt,
609
- providedUpdatedAt: details.providedUpdatedAt,
608
+ currentVersion: details.currentVersion,
609
+ providedVersion: details.providedVersion,
610
610
  },
611
611
  },
612
612
  });
@@ -630,7 +630,18 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
630
630
  return async (request: Request): Promise<Response> => {
631
631
  const url = new URL(request.url);
632
632
  const requestLabel = `${request.method} ${url.pathname}`;
633
- const requestToken = extractToken(request, url);
633
+ if (url.searchParams.has("token")) {
634
+ return jsonResponse(401, {
635
+ ok: false,
636
+ error: {
637
+ code: "unauthorized",
638
+ message: "Board API requests must authenticate with the session cookie or authorization header",
639
+ },
640
+ });
641
+ }
642
+ const requestToken = url.pathname === "/api/snapshot/stream"
643
+ ? readCookieToken(request)
644
+ : extractHeaderOrCookieToken(request);
634
645
  // Plain `!==` instead of a constant-time compare is a deliberate choice
635
646
  // (System Hardening 0.4.2, finding 32). The board server only binds to
636
647
  // 127.0.0.1, the session token is a 256-bit cryptographically-random
@@ -1007,14 +1018,14 @@ export function createBoardApiHandler(context: BoardRouteContext): (request: Req
1007
1018
  });
1008
1019
  } catch (error: unknown) {
1009
1020
  // PreconditionFailedError is a typed signal from the *WithIfMatch
1010
- // CAS variants. It carries the freshly-fetched currentUpdatedAt so
1021
+ // CAS variants. It carries the freshly-fetched currentVersion so
1011
1022
  // the 409 payload is always consistent with the post-rollback state.
1012
1023
  if (error instanceof PreconditionFailedError) {
1013
1024
  return preconditionFailedResponse({
1014
1025
  entityKind: error.entityKind,
1015
1026
  entityId: error.entityId,
1016
- currentUpdatedAt: error.currentUpdatedAt,
1017
- providedUpdatedAt: error.providedUpdatedAt,
1027
+ currentVersion: error.currentVersion,
1028
+ providedVersion: error.providedVersion,
1018
1029
  });
1019
1030
  }
1020
1031
 
@@ -90,7 +90,7 @@ function isUnavailablePortError(error: unknown): boolean {
90
90
  }
91
91
 
92
92
  function buildBoardSessionCookie(token: string): string {
93
- return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
93
+ return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; Max-Age=86400; SameSite=Strict; HttpOnly`;
94
94
  }
95
95
 
96
96
  function readBoardSessionCookie(request: Request): string | null {
@@ -122,6 +122,13 @@ function isAuthenticatedBoardRequest(request: Request, url: URL, token: string):
122
122
  return cookieToken !== null && cookieToken === token;
123
123
  }
124
124
 
125
+ function buildTokenStrippedLocation(url: URL): string {
126
+ const redirectUrl = new URL(url);
127
+ redirectUrl.searchParams.delete("token");
128
+ const pathname = `/${redirectUrl.pathname.replace(/^\/+/u, "")}`;
129
+ return `${pathname}${redirectUrl.search}${redirectUrl.hash}`;
130
+ }
131
+
125
132
  function serializeInlineJson(value: unknown): string {
126
133
  return JSON.stringify(value)
127
134
  .replace(/</g, "\\u003c")
@@ -156,7 +163,7 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
156
163
  const paths = resolveStoragePaths(cwd);
157
164
  const boardRoot: string = paths.boardDir;
158
165
  const stateFile: string = resolve(paths.storageDir, BOARD_SERVER_STATE_FILENAME);
159
- const token: string = options.token ?? randomBytes(18).toString("hex");
166
+ const token: string = options.token ?? randomBytes(32).toString("hex");
160
167
  const eventBus: BoardEventBus = createBoardEventBus();
161
168
  const walWatcher: WalWatcher = startWalWatcher({
162
169
  db: database.db,
@@ -195,15 +202,15 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
195
202
  // and Referer headers free of the secret on the very first
196
203
  // navigation, severing the leakage surface that an open URL bar
197
204
  // would otherwise expose. The cookie carries auth from here on.
198
- const redirectUrl = new URL(url);
199
- redirectUrl.searchParams.delete("token");
200
- // Preserve the relative location so the redirect works regardless
201
- // of how the client reached us (loopback IP vs. localhost name).
202
- const location = `${redirectUrl.pathname}${redirectUrl.search}${redirectUrl.hash}`;
205
+ // Preserve a single-slash relative location so the redirect works
206
+ // regardless of how the client reached us, without creating a
207
+ // scheme-relative `//host` Location for odd incoming paths.
208
+ const location = buildTokenStrippedLocation(url);
203
209
  return new Response(null, {
204
210
  status: 302,
205
211
  headers: {
206
212
  ...responseHeaders,
213
+ "referrer-policy": "no-referrer",
207
214
  location: location.length > 0 ? location : "/",
208
215
  },
209
216
  });
@@ -34,6 +34,7 @@ interface BoardSnapshotSubtask {
34
34
  readonly owner: string | null;
35
35
  readonly createdAt: number;
36
36
  readonly updatedAt: number;
37
+ readonly version: number;
37
38
  readonly blockedBy: readonly string[];
38
39
  readonly blocks: readonly string[];
39
40
  readonly dependencyIds: readonly string[];
@@ -51,6 +52,7 @@ interface BoardSnapshotTask {
51
52
  readonly owner: string | null;
52
53
  readonly createdAt: number;
53
54
  readonly updatedAt: number;
55
+ readonly version: number;
54
56
  readonly blockedBy: readonly string[];
55
57
  readonly blocks: readonly string[];
56
58
  readonly dependencyIds: readonly string[];
@@ -66,6 +68,7 @@ interface BoardSnapshotEpic {
66
68
  readonly status: string;
67
69
  readonly createdAt: number;
68
70
  readonly updatedAt: number;
71
+ readonly version: number;
69
72
  readonly taskIds: readonly string[];
70
73
  readonly counts: FlatCounts;
71
74
  readonly searchText: string;
@@ -169,6 +172,7 @@ function mapSnapshotSubtask(subtask: SubtaskRecord, indexes: ReturnType<typeof b
169
172
  owner: subtask.owner ?? null,
170
173
  createdAt: subtask.createdAt,
171
174
  updatedAt: subtask.updatedAt,
175
+ version: subtask.version,
172
176
  blockedBy: indexes.blockedByIdsBySource.get(subtask.id) ?? [],
173
177
  blocks: indexes.blocksByTarget.get(subtask.id) ?? [],
174
178
  dependencyIds: indexes.dependencyIdsBySource.get(subtask.id) ?? [],
@@ -188,6 +192,7 @@ function mapSnapshotTask(task: TaskRecord, taskSubtasks: readonly BoardSnapshotS
188
192
  owner: task.owner ?? null,
189
193
  createdAt: task.createdAt,
190
194
  updatedAt: task.updatedAt,
195
+ version: task.version,
191
196
  blockedBy: indexes.blockedByIdsBySource.get(task.id) ?? [],
192
197
  blocks: indexes.blocksByTarget.get(task.id) ?? [],
193
198
  dependencyIds: indexes.dependencyIdsBySource.get(task.id) ?? [],
@@ -205,6 +210,7 @@ function mapSnapshotEpic(epic: EpicRecord, epicTasks: readonly BoardSnapshotTask
205
210
  status: epic.status,
206
211
  createdAt: epic.createdAt,
207
212
  updatedAt: epic.updatedAt,
213
+ version: epic.version,
208
214
  taskIds: epicTasks.map((task) => task.id),
209
215
  counts: deriveFlatCounts(epicTasks),
210
216
  searchText: [epic.title, epic.description, ...epicTasks.map((task) => task.searchText)].join(" ").toLowerCase(),
@@ -22,6 +22,8 @@ import { TrackerDomain } from "../domain/tracker-domain";
22
22
  import { type BoardEventBus } from "./event-bus";
23
23
  import { buildBoardSnapshot, type BoardSnapshot } from "./snapshot";
24
24
 
25
+ const IN_PROCESS_WAL_SUPPRESS_MS = 500;
26
+
25
27
  interface CollectionDiff {
26
28
  readonly upserted: unknown[];
27
29
  readonly deletedIds: string[];
@@ -104,6 +106,17 @@ function diffById(previous: readonly unknown[] | undefined, current: readonly un
104
106
  return { upserted, deletedIds };
105
107
  }
106
108
 
109
+ function readMtime(path: string): number {
110
+ if (!existsSync(path)) {
111
+ return 0;
112
+ }
113
+ try {
114
+ return statSync(path).mtimeMs;
115
+ } catch {
116
+ return 0;
117
+ }
118
+ }
119
+
107
120
  export interface WalWatcherOptions {
108
121
  readonly db: Database;
109
122
  readonly databaseFile: string;
@@ -168,11 +181,20 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
168
181
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
169
182
  let closed = false;
170
183
  let failures = 0;
184
+ let lastSuppressedInProcessWriteAt = 0;
171
185
 
172
186
  function reconcile(): void {
173
187
  if (closed) {
174
188
  return;
175
189
  }
190
+ const inProcessWriteAt = options.eventBus.lastInProcessWriteAt;
191
+ if (
192
+ inProcessWriteAt > lastSuppressedInProcessWriteAt &&
193
+ Date.now() - inProcessWriteAt <= IN_PROCESS_WAL_SUPPRESS_MS
194
+ ) {
195
+ lastSuppressedInProcessWriteAt = inProcessWriteAt;
196
+ return;
197
+ }
176
198
 
177
199
  try {
178
200
  const fresh = buildSnapshot(domain);
@@ -235,17 +257,6 @@ export function startWalWatcher(options: WalWatcherOptions): WalWatcher {
235
257
  // than spurious watcher fires (e.g. atime-only updates on some filesystems).
236
258
  let lastWalMtime: number = readMtime(walFile);
237
259
 
238
- function readMtime(path: string): number {
239
- if (!existsSync(path)) {
240
- return 0;
241
- }
242
- try {
243
- return statSync(path).mtimeMs;
244
- } catch {
245
- return 0;
246
- }
247
- }
248
-
249
260
  function maybeScheduleReconcile(): void {
250
261
  const currentMtime = readMtime(walFile);
251
262
  // mtime can equal 0 when the WAL was just checkpointed and removed; treat
@@ -115,13 +115,13 @@ const EPIC_HELP = [
115
115
  "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete|progress|export> [options]",
116
116
  "",
117
117
  "Create:",
118
- " trekoon epic create --title \"...\" --description \"...\" [--status <status>]",
119
- " trekoon epic create --title \"...\" --description \"...\" [--task <spec>] [--subtask <spec>] [--dep <spec>]",
118
+ " trekoon --toon epic create --title \"...\" --description \"...\" [--status <status>]",
119
+ " trekoon --toon epic create --title \"...\" --description \"...\" [--task <spec>] [--subtask <spec>] [--dep <spec>]",
120
120
  " When the full tree is known, the second form creates everything in one shot",
121
121
  " and returns mappings/counts. Same compact spec grammar as epic expand.",
122
122
  "",
123
123
  "Expand:",
124
- " trekoon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
124
+ " trekoon --toon epic expand <epic-id> [--task <spec>] [--subtask <spec>] [--dep <spec>]",
125
125
  " --task <temp-key>|<title>|<description>|<status>",
126
126
  ` --subtask <parent-ref>|<temp-key>|<title>|<description>|<status> (${"@"}<temp-key> for new parents)`,
127
127
  ` --dep <source-ref>|<depends-on-ref> (refs can be IDs or ${"@"}<temp-key>)`,
@@ -441,8 +441,8 @@ const SUGGEST_HELP = [
441
441
 
442
442
  const SKILLS_HELP = [
443
443
  "Usage:",
444
- " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
445
- " trekoon skills install -g|--global [--editor opencode|claude|pi]",
444
+ " trekoon skills install [--link --editor opencode|claude|codex|pi] [--to <path>] [--allow-outside-repo]",
445
+ " trekoon skills install -g|--global [--editor opencode|claude|codex|pi]",
446
446
  " trekoon skills update",
447
447
  "",
448
448
  "Installs or refreshes the Trekoon skill so AI agents can plan and execute.",
@@ -451,7 +451,7 @@ const SKILLS_HELP = [
451
451
  " Creates a symlink at .agents/skills/trekoon pointing to the bundled source,",
452
452
  " so the skill always matches the installed CLI version.",
453
453
  " --link Also create an editor symlink named 'trekoon'.",
454
- " --editor <name> Required with --link (opencode|claude|pi).",
454
+ " --editor <name> Required with --link (opencode|claude|codex|pi).",
455
455
  " --to <path> Override the symlink root for --link only.",
456
456
  " --allow-outside-repo Allow links outside the repo (requires --link).",
457
457
  "",
@@ -10,11 +10,11 @@ import { type CliContext, type CliResult } from "../runtime/command-types";
10
10
 
11
11
  const SKILLS_USAGE = [
12
12
  "Usage:",
13
- " trekoon skills install [--link --editor opencode|claude|pi] [--to <path>] [--allow-outside-repo]",
14
- " trekoon skills install -g|--global [--editor opencode|claude|pi]",
13
+ " trekoon skills install [--link --editor opencode|claude|codex|pi] [--to <path>] [--allow-outside-repo]",
14
+ " trekoon skills install -g|--global [--editor opencode|claude|codex|pi]",
15
15
  " trekoon skills update",
16
16
  ].join("\n");
17
- const EDITOR_NAMES = ["opencode", "claude", "pi"] as const;
17
+ const EDITOR_NAMES = ["opencode", "claude", "codex", "pi"] as const;
18
18
  const ALLOW_OUTSIDE_REPO_FLAG = "allow-outside-repo";
19
19
 
20
20
  type EditorName = (typeof EDITOR_NAMES)[number];
@@ -88,6 +88,10 @@ function resolveLinkRoot(cwd: string, editor: EditorName, toOverride: string | u
88
88
  return join(cwd, ".claude", "skills");
89
89
  }
90
90
 
91
+ if (editor === "codex") {
92
+ return join(cwd, ".codex", "skills");
93
+ }
94
+
91
95
  return join(cwd, ".pi", "skills");
92
96
  }
93
97
 
@@ -222,6 +226,10 @@ function resolveEditorConfigDir(cwd: string, editor: EditorName): string {
222
226
  return join(cwd, ".claude");
223
227
  }
224
228
 
229
+ if (editor === "codex") {
230
+ return join(cwd, ".codex");
231
+ }
232
+
225
233
  return join(cwd, ".pi");
226
234
  }
227
235
 
@@ -235,6 +243,10 @@ function resolveGlobalEditorSkillsDir(editor: EditorName): string {
235
243
  return join(home, ".claude", "skills");
236
244
  }
237
245
 
246
+ if (editor === "codex") {
247
+ return join(home, ".codex", "skills");
248
+ }
249
+
238
250
  return join(home, ".pi", "skills");
239
251
  }
240
252
 
@@ -542,7 +554,7 @@ function runSkillsInstall(context: CliContext): CliResult {
542
554
 
543
555
  // Validate editor early (shared by both modes).
544
556
  if (rawEditor !== undefined && !EDITOR_NAMES.includes(rawEditor as EditorName)) {
545
- return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, pi", {
557
+ return invalidInput("skills.install", "Invalid --editor value. Use: opencode, claude, codex, pi", {
546
558
  editor: rawEditor,
547
559
  allowedEditors: EDITOR_NAMES,
548
560
  });
@@ -588,7 +600,7 @@ function runSkillsInstall(context: CliContext): CliResult {
588
600
  }
589
601
 
590
602
  if (wantsLink && rawEditor === undefined) {
591
- return invalidArgs("skills install --link requires --editor opencode|claude|pi.");
603
+ return invalidArgs("skills install --link requires --editor opencode|claude|codex|pi.");
592
604
  }
593
605
 
594
606
  const editor: EditorName | undefined = rawEditor as EditorName | undefined;