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.
- package/.agents/skills/trekoon/SKILL.md +97 -208
- package/.agents/skills/trekoon/reference/execution-with-team.md +87 -149
- package/.agents/skills/trekoon/reference/execution.md +170 -380
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +193 -330
- package/.agents/skills/trekoon/reference/sync.md +56 -103
- package/README.md +21 -7
- package/docs/ai-agents.md +48 -4
- package/docs/commands.md +2 -2
- package/docs/quickstart.md +3 -3
- package/package.json +1 -1
- package/src/board/assets/app.js +4 -2
- package/src/board/assets/components/Component.js +6 -8
- package/src/board/assets/state/actions.js +3 -0
- package/src/board/assets/state/api.js +48 -34
- package/src/board/assets/state/store.js +3 -0
- package/src/board/event-bus.ts +9 -0
- package/src/board/routes.ts +94 -83
- package/src/board/server.ts +14 -7
- package/src/board/snapshot.ts +6 -0
- package/src/board/wal-watcher.ts +22 -11
- package/src/commands/help.ts +6 -6
- package/src/commands/skills.ts +17 -5
- package/src/domain/mutation-service.ts +61 -42
- package/src/domain/tracker-domain.ts +20 -16
- package/src/domain/types.ts +3 -0
- package/src/export/render-markdown.ts +1 -2
- package/src/runtime/daemon.ts +110 -49
- package/src/storage/database.ts +9 -2
- package/src/storage/migrations.ts +19 -2
- package/src/sync/service.ts +47 -27
|
@@ -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) {
|
package/src/board/event-bus.ts
CHANGED
|
@@ -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();
|
package/src/board/routes.ts
CHANGED
|
@@ -84,7 +84,7 @@ function readCookieToken(request: Request): string | null {
|
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
|
290
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
enqueueRaw(
|
|
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 (
|
|
322
|
-
|
|
334
|
+
if (isBackpressured()) {
|
|
335
|
+
queueFullSnapshot(id);
|
|
323
336
|
return;
|
|
324
337
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
356
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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 —
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
596
|
-
readonly
|
|
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
|
|
604
|
+
message: "If-Match version does not match current version",
|
|
605
605
|
details: {
|
|
606
606
|
entityKind: details.entityKind,
|
|
607
607
|
entityId: details.entityId,
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1017
|
-
|
|
1027
|
+
currentVersion: error.currentVersion,
|
|
1028
|
+
providedVersion: error.providedVersion,
|
|
1018
1029
|
});
|
|
1019
1030
|
}
|
|
1020
1031
|
|
package/src/board/server.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
//
|
|
201
|
-
|
|
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
|
});
|
package/src/board/snapshot.ts
CHANGED
|
@@ -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(),
|
package/src/board/wal-watcher.ts
CHANGED
|
@@ -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
|
package/src/commands/help.ts
CHANGED
|
@@ -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
|
"",
|
package/src/commands/skills.ts
CHANGED
|
@@ -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;
|