@jeffober/thatch 0.1.42 → 0.1.44
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/README.md +11 -2
- package/artifacts/skills/thatch-clear-writing.md +77 -0
- package/artifacts/skills/thatch-coding-workflow.md +1 -0
- package/bin/thatch +281 -47
- package/docs/dev/README.md +9 -1
- package/docs/dev/features/README.md +1 -1
- package/docs/dev/features/cli.md +18 -11
- package/docs/dev/features/cross-session-chat.md +124 -40
- package/docs/dev/features/database.md +7 -3
- package/docs/dev/features/multi-host.md +1 -1
- package/docs/dev/features/session-lifecycle.md +24 -1
- package/docs/dev/gotchas.md +21 -0
- package/docs/dev/mcp-parity.md +2 -0
- package/docs/dev/setup-and-hooks.md +17 -7
- package/docs/dev/skills.md +1 -0
- package/docs/plans/thatch-actions.md +144 -0
- package/docs/user/README.md +19 -2
- package/docs/user/cli.md +35 -37
- package/docs/user/cross-session-chat.md +92 -19
- package/docs/user/skills.md +1 -0
- package/package.json +1 -1
- package/src/chat-names.ts +2 -2
- package/src/chat.ts +376 -147
- package/src/commands.ts +107 -0
- package/src/db.ts +54 -5
- package/src/debug.ts +44 -0
- package/src/git.ts +22 -0
- package/src/index.ts +264 -40
- package/src/mcp.ts +16 -0
- package/src/prompts.ts +30 -2
- package/src/setup.ts +203 -33
- package/src/skills.ts +1 -0
- package/src/tool-defs.ts +107 -27
- package/src/watchers.ts +51 -29
package/src/watchers.ts
CHANGED
|
@@ -273,6 +273,39 @@ export async function ghApiRun(apiArgs: string[]): Promise<unknown> {
|
|
|
273
273
|
/** Per-call timeout for gh api invocations. */
|
|
274
274
|
const GH_API_TIMEOUT_MS = 15_000;
|
|
275
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Reads a spawned process's output stream to EOF. A read ERROR is not EOF -
|
|
278
|
+
* the pipe may still be delivering - so the loop backs off briefly and keeps
|
|
279
|
+
* reading until the caller's drain race abandons it; only the abandonment's
|
|
280
|
+
* cancel settles the loop early (a cancelled host process must never be
|
|
281
|
+
* pinned by a pending read). Treating an error as EOF here would turn the
|
|
282
|
+
* abandonment path - the orphaned-child timeout message - into a fast, wrong
|
|
283
|
+
* exit.
|
|
284
|
+
* Exported for the drain unit tests.
|
|
285
|
+
*/
|
|
286
|
+
export async function drainStreamOutput(
|
|
287
|
+
reader: ReadableStreamDefaultReader,
|
|
288
|
+
isCancelled: () => boolean,
|
|
289
|
+
): Promise<string> {
|
|
290
|
+
let text = "";
|
|
291
|
+
const decoder = new TextDecoder();
|
|
292
|
+
for (;;) {
|
|
293
|
+
let res: { done: boolean; value?: any };
|
|
294
|
+
try {
|
|
295
|
+
res = await reader.read();
|
|
296
|
+
} catch {
|
|
297
|
+
if (isCancelled()) return text;
|
|
298
|
+
await new Promise<void>((resolve) => {
|
|
299
|
+
const t = setTimeout(resolve, 50);
|
|
300
|
+
t.unref?.();
|
|
301
|
+
});
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (res.done) return text;
|
|
305
|
+
text += decoder.decode(res.value, { stream: true });
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
276
309
|
/**
|
|
277
310
|
* One gh api call with the drain-race hardening: the pipe drains are raced
|
|
278
311
|
* against a hard deadline (timeout plus COMMAND_DRAIN_GRACE_MS) so a gh
|
|
@@ -289,23 +322,16 @@ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number):
|
|
|
289
322
|
const timeout = setTimeout(() => proc.kill("SIGKILL"), timeoutMs);
|
|
290
323
|
const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
|
|
291
324
|
let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
325
|
+
// Set by the abandonment path so the drain loops settle on their cancelled
|
|
326
|
+
// reads instead of retrying (a cancelled host must never be pinned).
|
|
327
|
+
let cancelled = false;
|
|
292
328
|
try {
|
|
293
329
|
const stdoutReader = proc.stdout.getReader();
|
|
294
330
|
const stderrReader = proc.stderr.getReader();
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const { done, value } = await reader.read();
|
|
300
|
-
if (done) break;
|
|
301
|
-
text += commandTargetLabel.length === -1 ? "" : new TextDecoder().decode(value, { stream: true });
|
|
302
|
-
}
|
|
303
|
-
} catch {
|
|
304
|
-
// Cancelled or errored: keep what was read.
|
|
305
|
-
}
|
|
306
|
-
return text;
|
|
307
|
-
};
|
|
308
|
-
const drain = Promise.all([readAll(stdoutReader), proc.exited]);
|
|
331
|
+
const drain = Promise.all([
|
|
332
|
+
drainStreamOutput(stdoutReader, () => cancelled),
|
|
333
|
+
proc.exited,
|
|
334
|
+
]);
|
|
309
335
|
// If the drain does not finish by the deadline (orphaned grandchild
|
|
310
336
|
// holding the pipes), abandon it - and cancel the readers so the pending
|
|
311
337
|
// reads settle instead of pinning the host process at shutdown.
|
|
@@ -317,13 +343,14 @@ export async function ghApiRunWithTimeout(apiArgs: string[], timeoutMs: number):
|
|
|
317
343
|
}),
|
|
318
344
|
]);
|
|
319
345
|
if (raced === null) {
|
|
346
|
+
cancelled = true;
|
|
320
347
|
void stdoutReader.cancel().catch(() => {});
|
|
321
348
|
void stderrReader.cancel().catch(() => {});
|
|
322
349
|
throw new Error(`gh api ${apiArgs.join(" ")} timed out (orphaned child holding the output pipe; killed at the ${timeoutMs}ms timeout, drains abandoned after ${Math.round(deadlineMs / 1000)}s)`);
|
|
323
350
|
}
|
|
324
351
|
if (raced.exitCode !== 0) {
|
|
325
352
|
const racedErr = await Promise.race([
|
|
326
|
-
|
|
353
|
+
drainStreamOutput(stderrReader, () => cancelled),
|
|
327
354
|
new Promise<string>((resolve) => {
|
|
328
355
|
const t = setTimeout(() => resolve(""), deadlineMs);
|
|
329
356
|
t.unref?.();
|
|
@@ -420,26 +447,20 @@ export async function runWatchedCommand(command: string, cwd: string, timeoutMs:
|
|
|
420
447
|
}, timeoutMs);
|
|
421
448
|
const deadlineMs = timeoutMs + COMMAND_DRAIN_GRACE_MS;
|
|
422
449
|
let raceTimer: ReturnType<typeof setTimeout> | undefined = undefined;
|
|
450
|
+
// Set by the abandonment path so the drain loops settle on their cancelled
|
|
451
|
+
// reads instead of retrying (a cancelled host must never be pinned).
|
|
452
|
+
let cancelled = false;
|
|
423
453
|
try {
|
|
424
454
|
// Explicit readers instead of Response.text(): a held reader lock keeps
|
|
425
455
|
// stream.cancel() from working, but reader.cancel() both settles the
|
|
426
456
|
// pending reads and releases the host when the deadline wins.
|
|
427
457
|
const stdoutReader = proc.stdout.getReader();
|
|
428
458
|
const stderrReader = proc.stderr.getReader();
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (done) break;
|
|
435
|
-
text += new TextDecoder().decode(value, { stream: true });
|
|
436
|
-
}
|
|
437
|
-
} catch {
|
|
438
|
-
// Cancelled or errored: keep what was read.
|
|
439
|
-
}
|
|
440
|
-
return text;
|
|
441
|
-
};
|
|
442
|
-
const drain = Promise.all([readAll(stdoutReader), readAll(stderrReader), proc.exited]);
|
|
459
|
+
const drain = Promise.all([
|
|
460
|
+
drainStreamOutput(stdoutReader, () => cancelled),
|
|
461
|
+
drainStreamOutput(stderrReader, () => cancelled),
|
|
462
|
+
proc.exited,
|
|
463
|
+
]);
|
|
443
464
|
// If the drain does not finish by the deadline (orphaned grandchild
|
|
444
465
|
// holding the pipes), abandon it - and cancel the readers so the pending
|
|
445
466
|
// reads settle instead of pinning the host process at shutdown.
|
|
@@ -453,6 +474,7 @@ export async function runWatchedCommand(command: string, cwd: string, timeoutMs:
|
|
|
453
474
|
}),
|
|
454
475
|
]);
|
|
455
476
|
if (result === null) {
|
|
477
|
+
cancelled = true;
|
|
456
478
|
void stdoutReader.cancel().catch(() => {});
|
|
457
479
|
void stderrReader.cancel().catch(() => {});
|
|
458
480
|
return { exitCode: 124, timedOut: true, stderr: "", durationMs: Date.now() - startedAt };
|