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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -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
- return new Uint8Array(await res.arrayBuffer());
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`). Returns `undefined` on any failure — the
425
- * message still delivers as text-only rather than being dropped entirely. */
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
+
@@ -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(hint: KeyHint, color = true): string {
39
- const key = color ? chalk.cyan.bold(hint.key) : hint.key;
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
+ }