jeo-code 0.9.1 → 0.9.2
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/CHANGELOG.md +19 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/compaction.ts +84 -0
- package/src/agent/config-schema.ts +11 -0
- package/src/agent/notify/telegram-api.ts +47 -2
- package/src/agent/notify/telegram-daemon.ts +11 -3
- package/src/cli.ts +7 -0
- package/src/commands/launch/slash-views.ts +34 -0
- package/src/commands/launch.ts +31 -1
- package/src/tui/app.ts +1 -1
- package/src/tui/components/hints.ts +27 -3
- package/src/tui/components/slash.ts +1 -0
- package/src/util/crash-log.ts +121 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [0.9.2] - 2026-07-26
|
|
12
|
+
_Released the jeo-native GJC v0.11.6–v0.11.9 parity improvements after a complete cross-audit and live verification pass._
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Bounded, non-destructive `/handoff [focus]` summaries** (`src/agent/compaction.ts`, `src/commands/launch.ts`) — generates a session handoff document without mutating history, preserves touched-file references, supports an optional configured focus, and exposes the command through help/autocomplete.
|
|
16
|
+
- **Bounded inbound Telegram attachment downloads** (`src/agent/notify/telegram-api.ts`) — rejects oversized bodies before/while buffering and keeps caption delivery text-only when an attachment is rejected.
|
|
17
|
+
- **Platform-native TUI modifier hints** (`src/tui/components/hints.ts`, `src/tui/app.ts`) — renders macOS `⌃` labels or portable `Ctrl+` labels with an ASCII fallback.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Durable fatal crash breadcrumbs** (`src/util/crash-log.ts`) — writes owner-only, UTF-8-bounded, secret-redacted crash records before fatal stderr handling, resetting at the file ceiling and never masking the original exception.
|
|
21
|
+
|
|
22
|
+
### Verified
|
|
23
|
+
- Focused parity suite: 191 pass / 0 fail across handoff, crash-log, Telegram attachment, TUI hint, slash registry, and autocomplete tests.
|
|
24
|
+
- `bun run typecheck`: clean.
|
|
25
|
+
- Live piped `jeo --no-tui` `/handoff` scenario: short history is rejected non-destructively with an explicit message.
|
|
26
|
+
- Full `bun test` was attempted for 300 seconds but remained affected by pre-existing host-load timeouts/failures in write-parallel, team-parallel, engine-multitool, team-subagent, and daemon timing tests; no focused parity test failed.
|
|
27
|
+
|
|
9
28
|
## [0.9.1] - 2026-07-24
|
|
10
29
|
_User request (paraphrased, Korean): "make the TUI resize responsively like gjc's TUI when terminal width/height changes, without breaking the layout — also borrow forge's layout and table components." Investigated the existing resize infrastructure first (already extensive: throttled live-frame resize repaint, a poll-based SIGWINCH safety net, resize-aware interactive pickers) before assuming a gap. Found one concrete un-responsive panel — `jeo doctor`'s Provider connectivity table — and, while rebuilding it on forge's own box-drawn table renderer, also found and fixed a real, previously-unknown overflow bug in `formatForgeBox` itself, shared by every tool card across the whole live TUI._
|
|
11
30
|
|
package/README.ja.md
CHANGED
|
@@ -284,11 +284,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
284
284
|
## 変更履歴 (Changelog)
|
|
285
285
|
|
|
286
286
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
287
|
+
- **[Unreleased]**
|
|
288
|
+
- **[0.9.2]** (2026-07-26) — Released the jeo-native GJC v0.11.6–v0.11.9 parity improvements after a complete cross-audit and live verification pass.
|
|
287
289
|
- **[0.9.1]** (2026-07-24) — User request (paraphrased, Korean): "make the TUI resize responsively like gjc's TUI when terminal width/height changes, without breaking the layout — also borrow forge's layout and table components." Investigated the existing resize infrastructure first (already extensive: throttled live-frame resize repaint, a poll-based SIGWINCH safety net, resize-aware interactive pickers) before assuming a gap. Found one concrete un-responsive panel — `jeo doctor`'s Provider connectivity table — and, while rebuilding it on forge's own box-drawn table renderer, also found and fixed a real, previously-unknown overflow bug in `formatForgeBox` itself, shared by every tool card across the whole live TUI.
|
|
288
290
|
- **[0.9.0]** (2026-07-22) — Session-scoped async execution, detached subagent control, background monitors, and parallel TUI activity are now documented and shipped for the gjc-parity release line.
|
|
289
291
|
- **[0.8.42]** (2026-07-21) — Request (paraphrased): "adopt gjc's subagent execution model and TUI parallel-processing monitoring — all the async/parallel parts — into jeo-code, with real operational verification." Investigated live before assuming a gap: jeo already has a mature, gjc-parity concurrent fan-out worker pool (`MAX_FANOUT`-bounded `Promise.all`, broadcast steering hub, detached/subagent/job/irc tools, a live TUI panel for concurrent subagents). Booted real `jeo --tmux` sessions with real credentials and repeatedly drove a genuine 3-file concurrent executor fan-out to verify this stack end to end — not to re-implement what already works, but to find what's actually broken in it. Found and fixed three real, live-reproduced bugs across the fan-out call path, none of them "missing gjc parity" — all pre-existing defects in jeo's own already-built implementation.
|
|
290
|
-
- **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
|
|
291
|
-
- **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
|
|
292
292
|
|
|
293
293
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
294
294
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -285,11 +285,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
285
285
|
## 변경 이력 (Changelog)
|
|
286
286
|
|
|
287
287
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
288
|
+
- **[Unreleased]**
|
|
289
|
+
- **[0.9.2]** (2026-07-26) — Released the jeo-native GJC v0.11.6–v0.11.9 parity improvements after a complete cross-audit and live verification pass.
|
|
288
290
|
- **[0.9.1]** (2026-07-24) — User request (paraphrased, Korean): "make the TUI resize responsively like gjc's TUI when terminal width/height changes, without breaking the layout — also borrow forge's layout and table components." Investigated the existing resize infrastructure first (already extensive: throttled live-frame resize repaint, a poll-based SIGWINCH safety net, resize-aware interactive pickers) before assuming a gap. Found one concrete un-responsive panel — `jeo doctor`'s Provider connectivity table — and, while rebuilding it on forge's own box-drawn table renderer, also found and fixed a real, previously-unknown overflow bug in `formatForgeBox` itself, shared by every tool card across the whole live TUI.
|
|
289
291
|
- **[0.9.0]** (2026-07-22) — Session-scoped async execution, detached subagent control, background monitors, and parallel TUI activity are now documented and shipped for the gjc-parity release line.
|
|
290
292
|
- **[0.8.42]** (2026-07-21) — Request (paraphrased): "adopt gjc's subagent execution model and TUI parallel-processing monitoring — all the async/parallel parts — into jeo-code, with real operational verification." Investigated live before assuming a gap: jeo already has a mature, gjc-parity concurrent fan-out worker pool (`MAX_FANOUT`-bounded `Promise.all`, broadcast steering hub, detached/subagent/job/irc tools, a live TUI panel for concurrent subagents). Booted real `jeo --tmux` sessions with real credentials and repeatedly drove a genuine 3-file concurrent executor fan-out to verify this stack end to end — not to re-implement what already works, but to find what's actually broken in it. Found and fixed three real, live-reproduced bugs across the fan-out call path, none of them "missing gjc parity" — all pre-existing defects in jeo's own already-built implementation.
|
|
291
|
-
- **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
|
|
292
|
-
- **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
|
|
293
293
|
|
|
294
294
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
295
295
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -292,11 +292,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
292
292
|
## Changelog
|
|
293
293
|
|
|
294
294
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
295
|
+
- **[Unreleased]**
|
|
296
|
+
- **[0.9.2]** (2026-07-26) — Released the jeo-native GJC v0.11.6–v0.11.9 parity improvements after a complete cross-audit and live verification pass.
|
|
295
297
|
- **[0.9.1]** (2026-07-24) — User request (paraphrased, Korean): "make the TUI resize responsively like gjc's TUI when terminal width/height changes, without breaking the layout — also borrow forge's layout and table components." Investigated the existing resize infrastructure first (already extensive: throttled live-frame resize repaint, a poll-based SIGWINCH safety net, resize-aware interactive pickers) before assuming a gap. Found one concrete un-responsive panel — `jeo doctor`'s Provider connectivity table — and, while rebuilding it on forge's own box-drawn table renderer, also found and fixed a real, previously-unknown overflow bug in `formatForgeBox` itself, shared by every tool card across the whole live TUI.
|
|
296
298
|
- **[0.9.0]** (2026-07-22) — Session-scoped async execution, detached subagent control, background monitors, and parallel TUI activity are now documented and shipped for the gjc-parity release line.
|
|
297
299
|
- **[0.8.42]** (2026-07-21) — Request (paraphrased): "adopt gjc's subagent execution model and TUI parallel-processing monitoring — all the async/parallel parts — into jeo-code, with real operational verification." Investigated live before assuming a gap: jeo already has a mature, gjc-parity concurrent fan-out worker pool (`MAX_FANOUT`-bounded `Promise.all`, broadcast steering hub, detached/subagent/job/irc tools, a live TUI panel for concurrent subagents). Booted real `jeo --tmux` sessions with real credentials and repeatedly drove a genuine 3-file concurrent executor fan-out to verify this stack end to end — not to re-implement what already works, but to find what's actually broken in it. Found and fixed three real, live-reproduced bugs across the fan-out call path, none of them "missing gjc parity" — all pre-existing defects in jeo's own already-built implementation.
|
|
298
|
-
- **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
|
|
299
|
-
- **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
|
|
300
300
|
|
|
301
301
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
302
302
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -284,11 +284,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
284
284
|
## 更新日志 (Changelog)
|
|
285
285
|
|
|
286
286
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
287
|
+
- **[Unreleased]**
|
|
288
|
+
- **[0.9.2]** (2026-07-26) — Released the jeo-native GJC v0.11.6–v0.11.9 parity improvements after a complete cross-audit and live verification pass.
|
|
287
289
|
- **[0.9.1]** (2026-07-24) — User request (paraphrased, Korean): "make the TUI resize responsively like gjc's TUI when terminal width/height changes, without breaking the layout — also borrow forge's layout and table components." Investigated the existing resize infrastructure first (already extensive: throttled live-frame resize repaint, a poll-based SIGWINCH safety net, resize-aware interactive pickers) before assuming a gap. Found one concrete un-responsive panel — `jeo doctor`'s Provider connectivity table — and, while rebuilding it on forge's own box-drawn table renderer, also found and fixed a real, previously-unknown overflow bug in `formatForgeBox` itself, shared by every tool card across the whole live TUI.
|
|
288
290
|
- **[0.9.0]** (2026-07-22) — Session-scoped async execution, detached subagent control, background monitors, and parallel TUI activity are now documented and shipped for the gjc-parity release line.
|
|
289
291
|
- **[0.8.42]** (2026-07-21) — Request (paraphrased): "adopt gjc's subagent execution model and TUI parallel-processing monitoring — all the async/parallel parts — into jeo-code, with real operational verification." Investigated live before assuming a gap: jeo already has a mature, gjc-parity concurrent fan-out worker pool (`MAX_FANOUT`-bounded `Promise.all`, broadcast steering hub, detached/subagent/job/irc tools, a live TUI panel for concurrent subagents). Booted real `jeo --tmux` sessions with real credentials and repeatedly drove a genuine 3-file concurrent executor fan-out to verify this stack end to end — not to re-implement what already works, but to find what's actually broken in it. Found and fixed three real, live-reproduced bugs across the fan-out call path, none of them "missing gjc parity" — all pre-existing defects in jeo's own already-built implementation.
|
|
290
|
-
- **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
|
|
291
|
-
- **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
|
|
292
292
|
|
|
293
293
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
294
294
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/compaction.ts
CHANGED
|
@@ -486,3 +486,87 @@ export async function maybeCompact(
|
|
|
486
486
|
touchedFiles: touched.length ? touched : undefined,
|
|
487
487
|
};
|
|
488
488
|
}
|
|
489
|
+
export interface HandoffOptions {
|
|
490
|
+
model?: string;
|
|
491
|
+
/** Cap the summarizer prompt/output so a handoff document itself cannot balloon context. */
|
|
492
|
+
maxSummaryInputTokens?: number;
|
|
493
|
+
/** Optional focus text — CLI-typed `/handoff <focus>` argument, or the
|
|
494
|
+
* configured `compaction.handoffFocus` default when no argument is given.
|
|
495
|
+
* APPEND-ONLY: rendered as its own trailing section, never merged into or
|
|
496
|
+
* replacing the base handoff sections above it. */
|
|
497
|
+
focus?: string;
|
|
498
|
+
signal?: AbortSignal;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export interface HandoffResult {
|
|
502
|
+
ok: boolean;
|
|
503
|
+
document?: string;
|
|
504
|
+
error?: string;
|
|
505
|
+
touchedFiles?: string[];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Below this many non-system messages there is nothing meaningful to hand off. */
|
|
509
|
+
const MIN_HANDOFF_MESSAGES = 2;
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Generate a bounded, read-only handoff document from the CURRENT history —
|
|
513
|
+
* jeo-native subset of gjc `/handoff` parity. Unlike `maybeCompact`, this
|
|
514
|
+
* NEVER mutates `history`: jeo has no ACP/SDK-broker managed-session boundary
|
|
515
|
+
* to hand off INTO, so this is a display/export operation (like
|
|
516
|
+
* `exportSession`), not a session-transition primitive. It reuses the same
|
|
517
|
+
* LLM summarizer and touched-file extraction as compaction so the base shape
|
|
518
|
+
* (state/decisions/files/open TODOs) matches what `/compact` already
|
|
519
|
+
* produces, then appends the optional focus text as its OWN trailing
|
|
520
|
+
* section — append-only, it never edits the base sections.
|
|
521
|
+
*/
|
|
522
|
+
export async function buildHandoffDocument(
|
|
523
|
+
history: Message[],
|
|
524
|
+
opts: HandoffOptions = {},
|
|
525
|
+
): Promise<HandoffResult> {
|
|
526
|
+
const hasSystem = history.length > 0 && history[0].role === "system";
|
|
527
|
+
const body = history.slice(hasSystem ? 1 : 0);
|
|
528
|
+
if (body.length < MIN_HANDOFF_MESSAGES) {
|
|
529
|
+
return {
|
|
530
|
+
ok: false,
|
|
531
|
+
error: `Nothing to hand off yet — need at least ${MIN_HANDOFF_MESSAGES} conversation messages (have ${body.length}).`,
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const maxSummaryInputTokens = opts.maxSummaryInputTokens ?? DEFAULT_SUMMARY_INPUT_TOKENS;
|
|
536
|
+
const formatted = formatMessagesForSummaryByTokens(body, maxSummaryInputTokens);
|
|
537
|
+
const touched = extractTouchedFiles(body);
|
|
538
|
+
const touchedNote = touched.length
|
|
539
|
+
? `\nFiles touched in this session (PRESERVE this list verbatim): ${touched.join(", ")}`
|
|
540
|
+
: "";
|
|
541
|
+
|
|
542
|
+
const systemPrompt =
|
|
543
|
+
"Write a concise handoff document for the next person/agent continuing this coding session. " +
|
|
544
|
+
"Capture: current goal/state, key decisions, files touched, open TODOs, and approaches already tried " +
|
|
545
|
+
"and failed (with cause) so they are not repeated. Be concise." +
|
|
546
|
+
touchedNote;
|
|
547
|
+
|
|
548
|
+
let summary: string;
|
|
549
|
+
try {
|
|
550
|
+
summary = await callLlm(
|
|
551
|
+
[{ role: "user", content: formatted }],
|
|
552
|
+
{ model: opts.model, systemPrompt, reasoningEffort: "none", signal: opts.signal },
|
|
553
|
+
);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
if (opts.signal?.aborted) {
|
|
556
|
+
return { ok: false, error: "Handoff cancelled." };
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
ok: false,
|
|
560
|
+
error: `Handoff summary failed: ${(err as Error)?.message ?? "error"}. History was left untouched — retry, or use /export for a raw transcript.`,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const bounded = truncateRecentContentByTokens(summary, maxSummaryInputTokens);
|
|
565
|
+
const filesHeader = touched.length ? `Files touched: ${touched.join(", ")}\n` : "";
|
|
566
|
+
|
|
567
|
+
const sections = ["# Session Handoff", "", `${filesHeader}${bounded}`.trim()];
|
|
568
|
+
const focus = opts.focus?.trim();
|
|
569
|
+
if (focus) sections.push("", "## Focus", "", focus);
|
|
570
|
+
|
|
571
|
+
return { ok: true, document: sections.join("\n"), touchedFiles: touched.length ? touched : undefined };
|
|
572
|
+
}
|
|
@@ -220,6 +220,17 @@ export const ConfigSchema = z
|
|
|
220
220
|
enabled: z.boolean().optional(),
|
|
221
221
|
})
|
|
222
222
|
.optional(),
|
|
223
|
+
/** Threaded through the existing compaction/summary settings (gjc `/handoff`
|
|
224
|
+
* parity, jeo-native subset). `handoffFocus` is an operator-configured
|
|
225
|
+
* default focus text for the interactive `/handoff [focus]` command —
|
|
226
|
+
* used ONLY when the command is invoked with no CLI-typed focus argument,
|
|
227
|
+
* and always appended as its own trailing section (never merged into or
|
|
228
|
+
* replacing the generated handoff document's base sections). */
|
|
229
|
+
compaction: z
|
|
230
|
+
.object({
|
|
231
|
+
handoffFocus: z.string().optional(),
|
|
232
|
+
})
|
|
233
|
+
.optional(),
|
|
223
234
|
})
|
|
224
235
|
.passthrough();
|
|
225
236
|
|
|
@@ -100,6 +100,17 @@ export interface AnswerCallbackQueryOptions {
|
|
|
100
100
|
|
|
101
101
|
export type FetchLike = typeof fetch;
|
|
102
102
|
|
|
103
|
+
/** Conservative fixed default cap on a downloaded Telegram file (photo or
|
|
104
|
+
* document), enforced before/while buffering the response body so a
|
|
105
|
+
* malicious or oversized remote attachment can't allocate unbounded memory
|
|
106
|
+
* on the daemon. Same order of magnitude as jeo's local image-attachment
|
|
107
|
+
* bound (`MAX_ATTACH_IMAGE_BYTES` in `src/util/file-attachment.ts`) — kept
|
|
108
|
+
* as an independent constant rather than importing that one, since this
|
|
109
|
+
* guards a different codepath (network download vs. local `stat`+`readFile`).
|
|
110
|
+
* Injectable per call via {@link TelegramApi.downloadFile}'s `maxBytes`
|
|
111
|
+
* argument for callers/tests that need a different policy. */
|
|
112
|
+
export const MAX_TELEGRAM_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
|
113
|
+
|
|
103
114
|
export class TelegramApi {
|
|
104
115
|
constructor(
|
|
105
116
|
private readonly token: string,
|
|
@@ -206,15 +217,49 @@ export class TelegramApi {
|
|
|
206
217
|
* since this is new network I/O every caller must treat as fallible
|
|
207
218
|
* (gjc `downloadTelegramFile` parity, minus the retry logic gjc layers on
|
|
208
219
|
* top at the daemon level).
|
|
220
|
+
*
|
|
221
|
+
* Bounded by `maxBytes` (default {@link MAX_TELEGRAM_DOWNLOAD_BYTES}):
|
|
222
|
+
* a `content-length` over the cap short-circuits before any read, and
|
|
223
|
+
* the body is otherwise streamed and counted chunk-by-chunk so a
|
|
224
|
+
* remote server that lies about (or omits) `content-length` still can't
|
|
225
|
+
* buffer past the cap — the read is aborted and `undefined` returned the
|
|
226
|
+
* moment the running total exceeds it, never after buffering the whole
|
|
227
|
+
* oversized body first.
|
|
209
228
|
*/
|
|
210
|
-
async downloadFile(filePath: string): Promise<Uint8Array | undefined> {
|
|
229
|
+
async downloadFile(filePath: string, maxBytes: number = MAX_TELEGRAM_DOWNLOAD_BYTES): Promise<Uint8Array | undefined> {
|
|
211
230
|
if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) return undefined;
|
|
212
231
|
const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
|
|
213
232
|
const url = `https://api.telegram.org/file/bot${this.token}/${encodedPath}`;
|
|
214
233
|
try {
|
|
215
234
|
const res = await this.fetchImpl(url);
|
|
216
235
|
if (!res.ok) return undefined;
|
|
217
|
-
|
|
236
|
+
const declaredLength = Number(res.headers?.get?.("content-length"));
|
|
237
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) return undefined;
|
|
238
|
+
if (!res.body) {
|
|
239
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
240
|
+
return bytes.byteLength > maxBytes ? undefined : bytes;
|
|
241
|
+
}
|
|
242
|
+
const reader = res.body.getReader();
|
|
243
|
+
const chunks: Uint8Array[] = [];
|
|
244
|
+
let total = 0;
|
|
245
|
+
for (;;) {
|
|
246
|
+
const { done, value } = await reader.read();
|
|
247
|
+
if (done) break;
|
|
248
|
+
if (!value) continue;
|
|
249
|
+
total += value.byteLength;
|
|
250
|
+
if (total > maxBytes) {
|
|
251
|
+
await reader.cancel();
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
chunks.push(value);
|
|
255
|
+
}
|
|
256
|
+
const out = new Uint8Array(total);
|
|
257
|
+
let offset = 0;
|
|
258
|
+
for (const chunk of chunks) {
|
|
259
|
+
out.set(chunk, offset);
|
|
260
|
+
offset += chunk.byteLength;
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
218
263
|
} catch {
|
|
219
264
|
return undefined;
|
|
220
265
|
}
|
|
@@ -218,6 +218,10 @@ export interface TelegramDaemonOptions {
|
|
|
218
218
|
/** Injectable temp-file writer for downloaded inbound attachments — defaults
|
|
219
219
|
* to a real `os.tmpdir()` write. Returns the written file's absolute path. */
|
|
220
220
|
writeTempFile?: (bytes: Uint8Array, suggestedName: string) => Promise<string>;
|
|
221
|
+
/** Injectable cap (bytes) on an inbound photo/document download, passed
|
|
222
|
+
* through to `TelegramApi.downloadFile` — defaults to that method's own
|
|
223
|
+
* conservative fixed default (`MAX_TELEGRAM_DOWNLOAD_BYTES`) when unset. */
|
|
224
|
+
maxAttachmentBytes?: number;
|
|
221
225
|
}
|
|
222
226
|
|
|
223
227
|
/** Outbound payload the shared `RateLimitPool` schedules; `flushPool` maps each
|
|
@@ -421,14 +425,18 @@ export class TelegramDaemon {
|
|
|
421
425
|
|
|
422
426
|
/** Download a Telegram-hosted file into a local temp file for relay into a
|
|
423
427
|
* session's `[image #N]` attachment pipeline (`attachImagePaths`, see
|
|
424
|
-
* `src/util/file-attachment.ts`).
|
|
425
|
-
*
|
|
428
|
+
* `src/util/file-attachment.ts`). Bounded by `opts.maxAttachmentBytes`
|
|
429
|
+
* (or `TelegramApi.downloadFile`'s own default when unset) — an oversized
|
|
430
|
+
* remote file is rejected there and this returns `undefined` exactly as
|
|
431
|
+
* it would for any other download failure. Returns `undefined` on any
|
|
432
|
+
* failure — the message still delivers as text-only rather than being
|
|
433
|
+
* dropped entirely. */
|
|
426
434
|
private async downloadAttachment(fileId: string, suggestedName: string): Promise<string | undefined> {
|
|
427
435
|
try {
|
|
428
436
|
const got = await this.opts.telegram.getFile(fileId);
|
|
429
437
|
const filePath = got.result?.file_path;
|
|
430
438
|
if (!filePath) return undefined;
|
|
431
|
-
const bytes = await this.opts.telegram.downloadFile(filePath);
|
|
439
|
+
const bytes = await this.opts.telegram.downloadFile(filePath, this.opts.maxAttachmentBytes);
|
|
432
440
|
if (!bytes) return undefined;
|
|
433
441
|
const write = this.opts.writeTempFile ?? defaultWriteTempFile;
|
|
434
442
|
return await write(bytes, suggestedName);
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { dispatch } from "./cli/runner";
|
|
3
3
|
import { restoreTerminalState } from "./util/terminal-restore";
|
|
4
4
|
import { isBrokenPipeError, BROKEN_PIPE_EXIT_CODE } from "./util/broken-pipe";
|
|
5
|
+
import { writeCrashLog } from "./util/crash-log";
|
|
5
6
|
import pkg from "../package.json";
|
|
6
7
|
|
|
7
8
|
const APP_NAME = "jeo";
|
|
@@ -28,9 +29,15 @@ process.title = APP_NAME;
|
|
|
28
29
|
// socket peer) makes the NEXT stdout/stderr write throw EPIPE from an async tick
|
|
29
30
|
// outside any user try/catch — that is NOT a real fatal, so it exits quietly
|
|
30
31
|
// (matching the shell's own SIGPIPE exit code) instead of dumping the raw error.
|
|
32
|
+
//
|
|
33
|
+
// gjc PR #3051 parity: a genuine fatal also gets a best-effort, synchronous,
|
|
34
|
+
// owner-only, bounded crash-log entry (secrets redacted) under jeo's config
|
|
35
|
+
// dir BEFORE the clean stderr line — writeCrashLog() never throws, so it can
|
|
36
|
+
// never mask the original error.
|
|
31
37
|
const fatal = (err: unknown): never => {
|
|
32
38
|
restoreTerminalState();
|
|
33
39
|
if (isBrokenPipeError(err)) process.exit(BROKEN_PIPE_EXIT_CODE);
|
|
40
|
+
writeCrashLog(err);
|
|
34
41
|
const msg = (err as Error)?.message ?? String(err);
|
|
35
42
|
process.stderr.write(`error: ${msg}\n`);
|
|
36
43
|
process.exit(1);
|
|
@@ -74,3 +74,37 @@ export function historyViewLines(history: Message[], arg: string, columns: numbe
|
|
|
74
74
|
];
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Recognizes `/handoff` or `/handoff <focus text>` — returns `undefined` when
|
|
78
|
+
* `input` is not a handoff invocation. A missing or whitespace-only argument
|
|
79
|
+
* is treated as "no CLI focus" (the caller falls back to the configured
|
|
80
|
+
* `compaction.handoffFocus` default, if any). Pure string parsing so command
|
|
81
|
+
* recognition is testable without wiring up the REPL. */
|
|
82
|
+
export function parseHandoffCommand(input: string): { focus?: string } | undefined {
|
|
83
|
+
if (input === "/handoff") return {};
|
|
84
|
+
if (!input.startsWith("/handoff ")) return undefined;
|
|
85
|
+
const focus = input.slice("/handoff ".length).trim();
|
|
86
|
+
return focus ? { focus } : {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Renders a generated handoff document (or its failure) for scrollback —
|
|
90
|
+
* pure over the already-computed result so the bounded-document formatting
|
|
91
|
+
* and the non-destructive failure path are both testable without an LLM
|
|
92
|
+
* call or a live REPL. History is never touched by this presenter (or by
|
|
93
|
+
* the `/handoff` command it renders for) — it only ever reads and prints. */
|
|
94
|
+
export function handoffLines(
|
|
95
|
+
result: { ok: boolean; document?: string; error?: string },
|
|
96
|
+
columns: number | undefined,
|
|
97
|
+
): string[] {
|
|
98
|
+
if (!result.ok || !result.document) {
|
|
99
|
+
return [`(handoff not generated: ${result.error ?? "unknown error"} — history left untouched)`];
|
|
100
|
+
}
|
|
101
|
+
const sep = "─".repeat(Math.min(48, Math.max(20, (columns ?? 80) - 1)));
|
|
102
|
+
return [
|
|
103
|
+
sep,
|
|
104
|
+
"/handoff · bounded session summary (display/export only — history left untouched)",
|
|
105
|
+
sep,
|
|
106
|
+
...result.document.split("\n"),
|
|
107
|
+
sep,
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
|
package/src/commands/launch.ts
CHANGED
|
@@ -103,7 +103,7 @@ import { formatDuration, formatUsage } from "../tui/components/duration";
|
|
|
103
103
|
import { bashTool } from "../agent/tools";
|
|
104
104
|
|
|
105
105
|
import { loadProjectContext, withProjectContext } from "../agent/context-files";
|
|
106
|
-
import { maybeCompact, historyTokens } from "../agent/compaction";
|
|
106
|
+
import { maybeCompact, historyTokens, buildHandoffDocument } from "../agent/compaction";
|
|
107
107
|
import * as path from "node:path";
|
|
108
108
|
import * as fs from "node:fs";
|
|
109
109
|
import { listThemes, resolveTheme, themeGradient, accentPaint, accentShadowPaint } from "../tui/components/themes";
|
|
@@ -203,6 +203,8 @@ import {
|
|
|
203
203
|
hotkeysLines,
|
|
204
204
|
contextUsageLines,
|
|
205
205
|
historyViewLines,
|
|
206
|
+
parseHandoffCommand,
|
|
207
|
+
handoffLines,
|
|
206
208
|
} from "./launch/slash-views";
|
|
207
209
|
import { formatTranscript } from "../tui/components/transcript";
|
|
208
210
|
|
|
@@ -1996,6 +1998,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1996
1998
|
if (result && "lines" in result) for (const line of result.lines) console.log(line);
|
|
1997
1999
|
return;
|
|
1998
2000
|
}
|
|
2001
|
+
if (cmd === "/handoff" || cmd.startsWith("/handoff ")) {
|
|
2002
|
+
const parsed = parseHandoffCommand(cmd) ?? {};
|
|
2003
|
+
const cfg = await readGlobalConfig();
|
|
2004
|
+
const activeModel = sessionModel || defaultModel;
|
|
2005
|
+
// `Config` (state.ts) predates config-schema.ts's `compaction` field and is
|
|
2006
|
+
// out of this change's scope — read the passthrough-preserved value via a
|
|
2007
|
+
// narrow structural cast instead of widening the shared interface.
|
|
2008
|
+
const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
|
|
2009
|
+
const res = await buildHandoffDocument(history, { model: activeModel, focus });
|
|
2010
|
+
for (const line of handoffLines(res, process.stdout.columns)) console.log(line);
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
1999
2013
|
if (cmd === "/theme" || cmd.startsWith("/theme ")) {
|
|
2000
2014
|
const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
|
|
2001
2015
|
for (const line of decision.lines) console.log(line);
|
|
@@ -4273,6 +4287,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4273
4287
|
}
|
|
4274
4288
|
continue;
|
|
4275
4289
|
}
|
|
4290
|
+
if (input === "/handoff" || input.startsWith("/handoff ")) {
|
|
4291
|
+
// jeo-native subset of gjc `/handoff` parity: bounded, read-only handoff
|
|
4292
|
+
// document (like /compact's summary, framed for a session handoff) — jeo
|
|
4293
|
+
// has no ACP/SDK-broker managed-session boundary to hand off INTO, so this
|
|
4294
|
+
// NEVER mutates history (see buildHandoffDocument's doc comment).
|
|
4295
|
+
const parsed = parseHandoffCommand(input) ?? {};
|
|
4296
|
+
const activeModel = sessionModel || defaultModel;
|
|
4297
|
+
const cfg = await readGlobalConfig();
|
|
4298
|
+
// `Config` (state.ts) predates config-schema.ts's `compaction` field and is
|
|
4299
|
+
// out of this change's scope — read the passthrough-preserved value via a
|
|
4300
|
+
// narrow structural cast instead of widening the shared interface.
|
|
4301
|
+
const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
|
|
4302
|
+
const res = await buildHandoffDocument(history, { model: activeModel, focus });
|
|
4303
|
+
logLines(handoffLines(res, process.stdout.columns));
|
|
4304
|
+
continue;
|
|
4305
|
+
}
|
|
4276
4306
|
if (input === "/session" || input.startsWith("/session ")) {
|
|
4277
4307
|
const result = await runSessionSlash(input, {
|
|
4278
4308
|
cwd, history, noSession: flags.noSession, sessionId, sessionModel, rl,
|
package/src/tui/app.ts
CHANGED
|
@@ -1898,7 +1898,7 @@ export class LaunchTui {
|
|
|
1898
1898
|
// TTY only: keep the same query input box visible above the footer while the
|
|
1899
1899
|
// turn is running; typed text edits the next-prompt draft, not a side queue.
|
|
1900
1900
|
if (fit) {
|
|
1901
|
-
bottom.push(formatHintBar(undefined, { unicode: this.unicode, color: this.theme.color, cols: innerWidth }));
|
|
1901
|
+
bottom.push(formatHintBar(undefined, { unicode: this.unicode, color: this.theme.color, cols: innerWidth, platform: process.platform }));
|
|
1902
1902
|
bottom.push(...this.renderLiveInputBox(innerWidth));
|
|
1903
1903
|
}
|
|
1904
1904
|
// Live animated step strip appended to the footer when the turn has steps.
|
|
@@ -32,11 +32,35 @@ export interface HintBarOptions {
|
|
|
32
32
|
cols?: number;
|
|
33
33
|
/** Indent prefix (default two spaces). */
|
|
34
34
|
indent?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Host platform used to relabel modifier-key hints (e.g. "^C" -> "⌃C" on
|
|
37
|
+
* macOS vs "Ctrl+C" elsewhere). Omitted by default, which keeps every raw
|
|
38
|
+
* `KeyHint.key` string exactly as authored — existing callers/tests that
|
|
39
|
+
* never pass `platform` see no behavior change.
|
|
40
|
+
*/
|
|
41
|
+
platform?: NodeJS.Platform;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite a raw modifier-key token (e.g. "^C") into a platform-appropriate
|
|
46
|
+
* display label. Non-modifier keys ("Tab", "/help", …) pass through
|
|
47
|
+
* unchanged. macOS gets the compact "⌃" glyph when unicode is allowed;
|
|
48
|
+
* every other platform (and the ASCII fallback) spells out "Ctrl+".
|
|
49
|
+
*/
|
|
50
|
+
export function modifierKeyLabel(key: string, platform: NodeJS.Platform, unicode = true): string {
|
|
51
|
+
const m = /^\^(.+)$/.exec(key);
|
|
52
|
+
if (!m) return key;
|
|
53
|
+
return platform === "darwin" && unicode ? `\u2303${m[1]}` : `Ctrl+${m[1]}`;
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
/** Render one hint as "<key> <label>" with the key highlighted. */
|
|
38
|
-
export function formatHint(
|
|
39
|
-
|
|
57
|
+
export function formatHint(
|
|
58
|
+
hint: KeyHint,
|
|
59
|
+
color = true,
|
|
60
|
+
labelOpts: { platform?: NodeJS.Platform; unicode?: boolean } = {},
|
|
61
|
+
): string {
|
|
62
|
+
const rawKey = labelOpts.platform ? modifierKeyLabel(hint.key, labelOpts.platform, labelOpts.unicode !== false) : hint.key;
|
|
63
|
+
const key = color ? chalk.cyan.bold(rawKey) : rawKey;
|
|
40
64
|
const label = color ? chalk.gray(hint.label) : hint.label;
|
|
41
65
|
return `${key} ${label}`;
|
|
42
66
|
}
|
|
@@ -49,6 +73,6 @@ export function formatHintBar(hints: readonly KeyHint[] = DEFAULT_HINTS, opts: H
|
|
|
49
73
|
const indent = opts.indent ?? " ";
|
|
50
74
|
const sepRaw = unicode ? " · " : " | ";
|
|
51
75
|
const sep = color ? chalk.gray(sepRaw) : sepRaw;
|
|
52
|
-
const row = indent + hints.map(h => formatHint(h, color)).join(sep);
|
|
76
|
+
const row = indent + hints.map(h => formatHint(h, color, { platform: opts.platform, unicode })).join(sep);
|
|
53
77
|
return opts.cols ? truncate(row, opts.cols) : row;
|
|
54
78
|
}
|
|
@@ -35,6 +35,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
|
|
|
35
35
|
{ command: "/dump", usage: "/dump", description: "Copy the session transcript to the clipboard", group: "session" },
|
|
36
36
|
{ command: "/btw", usage: "/btw <question>", description: "Ask an ephemeral side question (history untouched)", group: "session" },
|
|
37
37
|
{ command: "/compact", usage: "/compact", description: "Summarize older turns to free context", group: "session" },
|
|
38
|
+
{ command: "/handoff", usage: "/handoff [focus]", description: "Generate a bounded handoff summary without mutating session history", group: "session" },
|
|
38
39
|
{ command: "/goal", usage: "/goal <condition>", description: "Set a natural language stop condition for the session", group: "session" },
|
|
39
40
|
{ command: "/model", usage: "/model [id|#N|save|thinking <level>|subagent <role> <model|#N|thinking L>]", description: "Show/switch model; picker can apply to default or any subagent role and set thinking", group: "models" },
|
|
40
41
|
{ command: "/fast", usage: "/fast [on|off|status]", description: "Toggle fast thinking mode when the active model supports it", group: "models" },
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort, synchronous, owner-only crash log — gjc PR #3051 parity, scoped to
|
|
3
|
+
* jeo-native persistence only (no daily-logger/archive infrastructure ported).
|
|
4
|
+
*
|
|
5
|
+
* Written directly from `src/cli.ts`'s process-level `uncaughtException`/
|
|
6
|
+
* `unhandledRejection` handler, BEFORE the clean "error: …" line hits stderr, so a
|
|
7
|
+
* fatal that tears the process down still leaves one bounded, redacted breadcrumb
|
|
8
|
+
* under jeo's existing config dir for later triage — without depending on the TUI
|
|
9
|
+
* being alive or the terminal being in a readable state.
|
|
10
|
+
*
|
|
11
|
+
* Every entry: (1) redacts common bearer/API-key/secret-shaped substrings, (2) is
|
|
12
|
+
* capped and UTF-8-safely truncated so one huge error can't itself blow the file
|
|
13
|
+
* past its ceiling in a single write, and (3) is appended to a single file that
|
|
14
|
+
* gets RESET (not grown forever) once that ceiling is crossed — a crash loop can
|
|
15
|
+
* never fill the disk. `writeCrashLog` NEVER throws: a broken log path/permission
|
|
16
|
+
* issue must never mask the real fatal error it's trying to record.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { jeoEnv } from "./env";
|
|
22
|
+
|
|
23
|
+
/** Total file size ceiling (bytes); once exceeded the log is RESET (overwritten
|
|
24
|
+
* with just the new entry) on the next write instead of appended to. */
|
|
25
|
+
export const CRASH_LOG_MAX_BYTES = 1_000_000;
|
|
26
|
+
/** Per-record cap (bytes), applied to the formatted message+stack body before it
|
|
27
|
+
* is written — independent of the file-level ceiling above. */
|
|
28
|
+
export const CRASH_LOG_MAX_ENTRY_BYTES = 8_000;
|
|
29
|
+
|
|
30
|
+
function crashLogDir(): string {
|
|
31
|
+
return jeoEnv("CONFIG_DIR") || path.join(os.homedir(), ".jeo");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function crashLogPath(): string {
|
|
35
|
+
return path.join(crashLogDir(), "crash.log");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Bearer tokens (e.g. `Authorization: Bearer sk-…`) carry the secret AFTER a
|
|
39
|
+
// space, so they need their own pattern — a generic `key: value` matcher stops
|
|
40
|
+
// at the first space and would leave the actual token unredacted.
|
|
41
|
+
const BEARER_RE = /\bBearer\s+[^\s"'`]+/gi;
|
|
42
|
+
// `key: value` / `key=value`, optionally quoted; value stops at the first
|
|
43
|
+
// delimiter (space, quote, comma, closing brace/bracket).
|
|
44
|
+
const KV_SECRET_RE =
|
|
45
|
+
/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|secret|password|passwd|token)\b(\s*[:=]\s*)("?)([^\s"'`,}\]]+)\3/gi;
|
|
46
|
+
// `"authorization": "value"` / `"apiKey": "value"` style JSON fields, including
|
|
47
|
+
// ones whose value legitimately contains spaces (e.g. an embedded auth header).
|
|
48
|
+
const JSON_SECRET_RE =
|
|
49
|
+
/("(?:api[_-]?key|access[_-]?token|refresh[_-]?token|auth[_-]?token|authorization|secret|password|passwd|token)"\s*:\s*")[^"]*(")/gi;
|
|
50
|
+
|
|
51
|
+
/** Redact common bearer/API-key/secret-shaped substrings. Conservative pattern
|
|
52
|
+
* match, not a secret scanner — best-effort only, applied before persistence. */
|
|
53
|
+
export function redactCrashText(input: string): string {
|
|
54
|
+
return input
|
|
55
|
+
.replace(BEARER_RE, "Bearer <redacted>")
|
|
56
|
+
.replace(JSON_SECRET_RE, "$1<redacted>$2")
|
|
57
|
+
.replace(KV_SECRET_RE, (_m, key: string, sep: string, quote: string) => `${key}${sep}${quote}<redacted>${quote}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Truncate to a byte budget without splitting a UTF-8 multi-byte sequence — a
|
|
61
|
+
* naive string/byte slice can cut mid-codepoint (or mid-surrogate-pair on the
|
|
62
|
+
* JS-string side), corrupting the tail into replacement characters on decode. */
|
|
63
|
+
function truncateUtf8(text: string, maxBytes: number): string {
|
|
64
|
+
const buf = Buffer.from(text, "utf-8");
|
|
65
|
+
if (buf.length <= maxBytes) return text;
|
|
66
|
+
let end = maxBytes;
|
|
67
|
+
// A UTF-8 continuation byte is 10xxxxxx (0x80-0xBF); back off the boundary so
|
|
68
|
+
// the cut never lands inside a multi-byte codepoint.
|
|
69
|
+
while (end > 0 && (buf[end]! & 0xc0) === 0x80) end--;
|
|
70
|
+
return `${buf.subarray(0, end).toString("utf-8")}…`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function describeReason(reason: unknown): string {
|
|
74
|
+
if (reason instanceof Error) {
|
|
75
|
+
const header = `${reason.name || "Error"}: ${reason.message || ""}`;
|
|
76
|
+
return reason.stack && reason.stack !== header ? `${header}\n${reason.stack}` : header;
|
|
77
|
+
}
|
|
78
|
+
if (typeof reason === "string") return reason;
|
|
79
|
+
if (reason === undefined) return "undefined";
|
|
80
|
+
if (reason === null) return "null";
|
|
81
|
+
try {
|
|
82
|
+
return JSON.stringify(reason) ?? String(reason);
|
|
83
|
+
} catch {
|
|
84
|
+
return String(reason);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatEntry(reason: unknown): string {
|
|
89
|
+
const redacted = redactCrashText(describeReason(reason));
|
|
90
|
+
const bounded = truncateUtf8(redacted, CRASH_LOG_MAX_ENTRY_BYTES);
|
|
91
|
+
return `[${new Date().toISOString()}] ${bounded}\n`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Append one crash record to the bounded, owner-only crash log (resetting the
|
|
95
|
+
* file first if it has crossed the size ceiling). Synchronous so it can run
|
|
96
|
+
* directly inside `uncaughtException`/`unhandledRejection` handlers before the
|
|
97
|
+
* process exits. NEVER throws. */
|
|
98
|
+
export function writeCrashLog(reason: unknown): void {
|
|
99
|
+
try {
|
|
100
|
+
const dir = crashLogDir();
|
|
101
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
102
|
+
const target = crashLogPath();
|
|
103
|
+
const entry = formatEntry(reason);
|
|
104
|
+
let reset = false;
|
|
105
|
+
try {
|
|
106
|
+
reset = fs.statSync(target).size > CRASH_LOG_MAX_BYTES;
|
|
107
|
+
} catch {
|
|
108
|
+
// No existing (or unreadable) file — nothing to reset, just create it.
|
|
109
|
+
}
|
|
110
|
+
if (reset) fs.writeFileSync(target, entry, { mode: 0o600 });
|
|
111
|
+
else fs.appendFileSync(target, entry, { mode: 0o600 });
|
|
112
|
+
try {
|
|
113
|
+
fs.chmodSync(target, 0o600); // owner-only even if the file pre-existed with looser perms
|
|
114
|
+
} catch {
|
|
115
|
+
// Best-effort permission tightening only.
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Crash logging is a diagnostic nicety, never a dependency of the fatal
|
|
119
|
+
// path itself — a write failure here must never mask the real fatal error.
|
|
120
|
+
}
|
|
121
|
+
}
|