@gajae-code/coding-agent 0.7.5 → 0.7.6
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 +5 -0
- package/dist/types/notifications/lifecycle-commands.d.ts +1 -13
- package/package.json +7 -7
- package/src/cli/fast-help.ts +29 -1
- package/src/gjc-runtime/launch-tmux.ts +14 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/notifications/lifecycle-commands.ts +19 -9
- package/src/notifications/lifecycle-control-runtime.ts +7 -2
- package/src/session/session-manager.ts +39 -16
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.6] - 2026-06-28
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Kept Codex `reasoning.encrypted_content` replay strings out of the resident large-text externalizer so resumed sessions preserve the provider's opaque encrypted reasoning payload instead of degrading it to a resident blob object.
|
|
5
10
|
## [0.7.5] - 2026-06-27
|
|
6
11
|
|
|
7
12
|
### Fixed
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Paired-chat /session_* command grammar (G009).
|
|
3
|
-
*
|
|
4
|
-
* Pure parser + shared target validator for the Telegram session-lifecycle
|
|
5
|
-
* commands. The daemon parses an inbound paired-chat message here, then attaches
|
|
6
|
-
* transport identity (chatId/updateId/token/requestId) and routes the resulting
|
|
7
|
-
* frame to the orchestrator. Keeping this pure makes the grammar, the MVP
|
|
8
|
-
* prompt-rejection, and target validation unit-testable without the daemon.
|
|
9
|
-
*
|
|
10
|
-
* MVP scope: an initial prompt (`-- <prompt>`) is REJECTED with usage text — no
|
|
11
|
-
* prompt text ever enters a frame, audit, log, or response until daemon-owned
|
|
12
|
-
* 0600 prompt refs are designed.
|
|
13
|
-
*/
|
|
14
1
|
import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
|
|
15
2
|
export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
|
|
16
3
|
/** A parsed, validated lifecycle command (transport identity added by caller). */
|
|
@@ -62,6 +49,7 @@ export declare function validateLifecycleTarget(verb: LifecycleCommandVerb, targ
|
|
|
62
49
|
reason: "invalid_target";
|
|
63
50
|
message: string;
|
|
64
51
|
};
|
|
52
|
+
export declare function normalizeLifecyclePath(value: string): string | undefined;
|
|
65
53
|
/**
|
|
66
54
|
* Map a lifecycle response/error to a user-facing Telegram message (G010).
|
|
67
55
|
*
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/coding-agent",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.6",
|
|
5
5
|
"description": "Gajae Code CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -52,12 +52,12 @@
|
|
|
52
52
|
"@agentclientprotocol/sdk": "0.21.0",
|
|
53
53
|
"@babel/parser": "^7.29.3",
|
|
54
54
|
"@mozilla/readability": "^0.6.0",
|
|
55
|
-
"@gajae-code/stats": "0.7.
|
|
56
|
-
"@gajae-code/agent-core": "0.7.
|
|
57
|
-
"@gajae-code/ai": "0.7.
|
|
58
|
-
"@gajae-code/natives": "0.7.
|
|
59
|
-
"@gajae-code/tui": "0.7.
|
|
60
|
-
"@gajae-code/utils": "0.7.
|
|
55
|
+
"@gajae-code/stats": "0.7.6",
|
|
56
|
+
"@gajae-code/agent-core": "0.7.6",
|
|
57
|
+
"@gajae-code/ai": "0.7.6",
|
|
58
|
+
"@gajae-code/natives": "0.7.6",
|
|
59
|
+
"@gajae-code/tui": "0.7.6",
|
|
60
|
+
"@gajae-code/utils": "0.7.6",
|
|
61
61
|
"@puppeteer/browsers": "^2.13.0",
|
|
62
62
|
"@types/turndown": "5.0.6",
|
|
63
63
|
"@xterm/headless": "^6.0.0",
|
package/src/cli/fast-help.ts
CHANGED
|
@@ -1,7 +1,35 @@
|
|
|
1
1
|
import { APP_NAME, CONFIG_DIR_NAME } from "@gajae-code/utils/dirs";
|
|
2
2
|
|
|
3
3
|
export function getExtraHelpText(): string {
|
|
4
|
-
return `
|
|
4
|
+
return `Commands:
|
|
5
|
+
${APP_NAME} [prompt] - Start an interactive coding session (default launch command)
|
|
6
|
+
${APP_NAME} launch - Start an explicit launch/session workflow
|
|
7
|
+
${APP_NAME} setup - Install GJC defaults or optional dependencies
|
|
8
|
+
${APP_NAME} session - List, inspect, create, remove, or attach sessions
|
|
9
|
+
${APP_NAME} state - Inspect or manage persisted GJC state
|
|
10
|
+
${APP_NAME} harness - Run harness control-plane commands
|
|
11
|
+
${APP_NAME} coordinator - Manage coordinator/runtime coordination helpers
|
|
12
|
+
${APP_NAME} team - Run tmux-backed coordinated execution
|
|
13
|
+
${APP_NAME} ultragoal - Run durable goal execution workflow
|
|
14
|
+
${APP_NAME} ralplan - Run consensus planning workflow
|
|
15
|
+
${APP_NAME} deep-interview - Run requirements interview workflow
|
|
16
|
+
${APP_NAME} skills - List/read embedded workflow skills
|
|
17
|
+
${APP_NAME} config - List, get, and set configuration values
|
|
18
|
+
${APP_NAME} notify - Send or test notifications
|
|
19
|
+
${APP_NAME} daemon - Manage background daemon helpers
|
|
20
|
+
${APP_NAME} mcp - Manage MCP server registrations
|
|
21
|
+
${APP_NAME} mcp-serve - Serve the MCP integration endpoint
|
|
22
|
+
${APP_NAME} contribute-pr - Prepare contribution/PR workflow artifacts
|
|
23
|
+
${APP_NAME} migrate - Run migration helpers
|
|
24
|
+
${APP_NAME} rlm - Run RLM helpers
|
|
25
|
+
${APP_NAME} update - Update GJC installation artifacts
|
|
26
|
+
${APP_NAME} plugin - Install, remove, and list plugins
|
|
27
|
+
${APP_NAME} web-search - Search the web from the CLI (alias: q)
|
|
28
|
+
${APP_NAME} codex-native-hook - Run Codex native hook integration
|
|
29
|
+
${APP_NAME} gc - Run garbage-collection/cleanup helpers
|
|
30
|
+
${APP_NAME} <command> --help - Show command-specific help
|
|
31
|
+
|
|
32
|
+
Environment Variables:
|
|
5
33
|
# Core Providers
|
|
6
34
|
ANTHROPIC_API_KEY - Anthropic Claude models
|
|
7
35
|
ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth (takes precedence over API key)
|
|
@@ -306,27 +306,33 @@ function truncateVisibleTail(value: string, maxWidth: number): string {
|
|
|
306
306
|
return `…${result}`;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
const GJC_TMUX_WINDOW_BRANCH_SEPARATOR = "-";
|
|
310
|
+
|
|
311
|
+
function sanitizeTmuxWindowTitleSegment(value: string): string {
|
|
312
|
+
return value.replace(/:+/g, "-");
|
|
313
|
+
}
|
|
314
|
+
|
|
309
315
|
function sanitizeTmuxWindowProjectName(project: string): string {
|
|
310
316
|
const trimmed = project.trim();
|
|
311
317
|
if (!trimmed || /^\.+$/.test(trimmed)) return "gjc";
|
|
312
|
-
if (trimmed.startsWith(".")) return `dot-${trimmed.replace(/^\.+/, "")}
|
|
313
|
-
return trimmed;
|
|
318
|
+
if (trimmed.startsWith(".")) return sanitizeTmuxWindowTitleSegment(`dot-${trimmed.replace(/^\.+/, "")}`);
|
|
319
|
+
return sanitizeTmuxWindowTitleSegment(trimmed);
|
|
314
320
|
}
|
|
315
321
|
|
|
316
322
|
export function buildGjcTmuxWindowTitle(cwd: string, branch: string | null | undefined): string {
|
|
317
323
|
const project = sanitizeTmuxWindowProjectName(path.basename(path.resolve(cwd)) || "gjc");
|
|
318
|
-
const trimmedBranch = branch?.trim();
|
|
324
|
+
const trimmedBranch = sanitizeTmuxWindowTitleSegment(branch?.trim() ?? "");
|
|
319
325
|
if (!trimmedBranch) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
|
|
320
326
|
|
|
321
|
-
const separatorWidth = visibleWidth(
|
|
327
|
+
const separatorWidth = visibleWidth(GJC_TMUX_WINDOW_BRANCH_SEPARATOR);
|
|
322
328
|
const projectWidth = visibleWidth(project);
|
|
323
|
-
const fullTitle = `${project}
|
|
329
|
+
const fullTitle = `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${trimmedBranch}`;
|
|
324
330
|
if (visibleWidth(fullTitle) <= GJC_TMUX_WINDOW_LABEL_MAX_WIDTH) return fullTitle;
|
|
325
331
|
|
|
326
332
|
const remainingBranchWidth = GJC_TMUX_WINDOW_LABEL_MAX_WIDTH - projectWidth - separatorWidth;
|
|
327
333
|
if (remainingBranchWidth <= 0) return truncateVisible(project, GJC_TMUX_WINDOW_LABEL_MAX_WIDTH);
|
|
328
334
|
|
|
329
|
-
return `${project}
|
|
335
|
+
return `${project}${GJC_TMUX_WINDOW_BRANCH_SEPARATOR}${truncateVisibleTail(trimmedBranch, remainingBranchWidth)}`;
|
|
330
336
|
}
|
|
331
337
|
|
|
332
338
|
function buildTmuxRenameWindowArgs(title: string, target?: string): string[] {
|
|
@@ -350,8 +356,8 @@ function renameExistingTmuxWindowIfNeeded(context: TmuxLaunchContext): void {
|
|
|
350
356
|
|
|
351
357
|
// Note: Windows is intentionally allowed here. Psmux supports
|
|
352
358
|
// `rename-window` and we want the leader window to inherit the
|
|
353
|
-
// project
|
|
354
|
-
// through PowerShell to a psmux backend.
|
|
359
|
+
// sanitized project-branch title even on native Windows, where
|
|
360
|
+
// gjc --tmux runs through PowerShell to a psmux backend.
|
|
355
361
|
|
|
356
362
|
const tty = context.tty ?? { stdin: Boolean(process.stdin.isTTY), stdout: Boolean(process.stdout.isTTY) };
|
|
357
363
|
if (!isInteractiveRootLaunch(context.parsed, tty)) return;
|
|
@@ -45,7 +45,7 @@ export const EMBEDDED_DOCS: Readonly<Record<string, string>> = {
|
|
|
45
45
|
"natives-text-search-pipeline.md": "# Natives Text/Search Pipeline\n\nThis document maps the `@gajae-code/natives` text/search/code surface from generated JS/TS exports to Rust N-API modules and back to JS result objects.\n\nTerminology follows `docs/natives-architecture.md`:\n\n- **Generated binding**: public API in `packages/natives/native/index.d.ts`.\n- **Rust module layer**: N-API exports in `crates/pi-natives/src/*`.\n- **Shared scan cache**: `fs_cache`-backed directory-entry cache used by discovery/search flows.\n\n## Implementation files\n\n- `packages/natives/native/index.d.ts`\n- `crates/pi-natives/src/grep.rs`\n- `crates/pi-natives/src/glob.rs`\n- `crates/pi-natives/src/glob_util.rs`\n- `crates/pi-natives/src/fs_cache.rs`\n- `crates/pi-natives/src/fd.rs`\n- `crates/pi-natives/src/ast.rs`\n- `crates/pi-natives/src/text.rs`\n- `crates/pi-natives/src/highlight.rs`\n- `crates/pi-natives/src/tokens.rs`\n\n## JS API ↔ Rust export mapping\n\n| JS API | Rust export (`#[napi]`, snake_case -> camelCase) | Rust module |\n| ------------------------------------------------------------------------------- | ------------------------------------------------ | -------------- |\n| `grep(options, onMatch?)` | `grep` | `grep.rs` |\n| `search(content, options)` | `search` | `grep.rs` |\n| `hasMatch(content, pattern, ignoreCase?, multiline?)` | `hasMatch` | `grep.rs` |\n| `fuzzyFind(options)` | `fuzzyFind` | `fd.rs` |\n| `glob(options, onMatch?)` | `glob` | `glob.rs` |\n| `invalidateFsScanCache(path?)` | `invalidateFsScanCache` | `fs_cache.rs` |\n| `astGrep(options)` | `astGrep` | `ast.rs` |\n| `astEdit(options)` | `astEdit` | `ast.rs` |\n| `wrapTextWithAnsi(text, width, tabWidth)` | `wrapTextWithAnsi` | `text.rs` |\n| `truncateToWidth(text, maxWidth, ellipsis, pad, tabWidth)` | `truncateToWidth` | `text.rs` |\n| `sliceWithWidth(line, startCol, length, strict, tabWidth)` | `sliceWithWidth` | `text.rs` |\n| `extractSegments(line, beforeEnd, afterStart, afterLen, strictAfter, tabWidth)` | `extractSegments` | `text.rs` |\n| `visibleWidth(text, tabWidth)` | `visibleWidth` | `text.rs` |\n| `highlightCode(code, lang, colors)` | `highlightCode` | `highlight.rs` |\n| `supportsLanguage(lang)` | `supportsLanguage` | `highlight.rs` |\n| `getSupportedLanguages()` | `getSupportedLanguages` | `highlight.rs` |\n| `countTokens(input, encoding?)` | `countTokens` | `tokens.rs` |\n\n## Pipeline overview by subsystem\n\n## 1) Regex search (`grep`, `search`, `hasMatch`)\n\n### Input/options flow\n\n1. Callers invoke generated native exports directly; there is no package-local TS wrapper that renames `search` to `searchContent`.\n2. Rust option structs in `grep.rs` deserialize camelCase fields (`ignoreCase`, `maxCount`, `contextBefore`, `contextAfter`, `maxColumns`, `timeoutMs`).\n3. `grep` creates `CancelToken` from `timeoutMs` + `AbortSignal` and runs inside `task::blocking(\"grep\", ...)`.\n4. `search` and `hasMatch` operate on provided string/`Uint8Array` content and do not scan the filesystem.\n\n### Execution branches\n\n- **In-memory branch**\n - `search` -> `search_sync` / search helpers over provided content bytes.\n - `hasMatch` compiles/checks pattern against provided content and returns a boolean.\n - No filesystem scan, no `fs_cache`.\n- **Single-file branch**\n - `grep` resolves path, checks metadata is file, and searches that file.\n- **Directory branch**\n - Optional cache lookup via `fs_cache::get_or_scan` when `cache: true`.\n - Fresh scan via `fs_cache::force_rescan` when `cache: false`.\n - Optional empty-result recheck when cached results are older than the empty-result recheck threshold.\n - Entry filtering: file-only + optional glob filter (`glob_util`) + optional type filter mapping (`js`, `ts`, `rust`, etc.).\n\n### Search/collection semantics\n\n- Regex engine: `grep_regex::RegexMatcherBuilder` with `ignoreCase` and `multiline`.\n- Context resolution:\n - `contextBefore/contextAfter` override legacy `context`.\n - Non-content modes do not collect context.\n- Output modes:\n - `content` -> one `GrepMatch` per hit.\n - `count` and `filesWithMatches` map to count-style entries (`lineNumber=0`, `line=\"\"`, `matchCount` set).\n- Limits:\n - Global `offset` and `maxCount` apply across files.\n - Parallel path is used only when `maxCount` is unset and `offset == 0`; otherwise sequential path preserves deterministic global offset/limit semantics.\n\n### Result shaping back to JS\n\n- Rust `SearchResult`/`GrepResult` fields map to TS interfaces via N-API object conversion.\n- Counters are clamped before crossing N-API where needed.\n- `GrepResult.limitReached` is optional and emitted when true.\n- Streaming callback receives each shaped `GrepMatch` for content or count-style entries.\n\n### Failure behavior\n\n- `search` returns `SearchResult.error` for regex/search failures instead of throwing.\n- `grep` rejects on hard errors such as invalid path, invalid glob/regex, or cancellation timeout/abort.\n- `hasMatch` returns a boolean on success and throws on invalid pattern/UTF-8 conversion errors.\n- File open/search errors in multi-file scans are skipped per-file; scan continues.\n\n### Malformed regex handling\n\n`grep.rs` sanitizes braces before regex compile:\n\n- Invalid repetition-like braces are escaped (`{`/`}` -> `\\{`/`\\}`) when they cannot form `{N}`, `{N,}`, `{N,M}`.\n- This prevents common literal-template fragments (for example `${platform}`) from failing as malformed repetition.\n- Remaining invalid regex syntax still returns a regex error.\n\n## 2) File discovery (`glob`) and fuzzy path search (`fuzzyFind`)\n\n`glob` and `fuzzyFind` share `fs_cache` scans; matching logic differs.\n\n### `glob` flow\n\n1. Caller passes `GlobOptions` directly. `pattern` and `path` are required in the generated type.\n2. Rust resolves the search path and compiles pattern via `glob_util::compile_glob`.\n3. Entry source:\n - `cache=true` -> `get_or_scan` + optional stale-empty `force_rescan`.\n - `cache=false` -> `force_rescan(..., store=false)` (fresh only).\n4. Filtering:\n - skip `.git` always;\n - skip `node_modules` unless requested (`includeNodeModules`) or pattern mentions `node_modules`;\n - apply glob match;\n - apply file-type filter; symlink `file`/`dir` filters resolve target metadata.\n5. Optional sort by mtime descending (`sortByMtime`) before truncating to `maxResults`.\n\n### `fuzzyFind` flow\n\n1. Rust implementation lives in `fd.rs`; generated export is `fuzzyFind`.\n2. Shared scan source from `fs_cache` with the same cache/no-cache split and stale-empty recheck policy.\n3. Scoring:\n - exact / starts-with / contains / subsequence-based fuzzy score;\n - separator/punctuation-normalized scoring path;\n - directory bonus and deterministic tie-break (`score desc`, then `path asc`).\n4. Symlink entries are excluded from fuzzy results.\n\n### Failure behavior\n\n- Invalid glob pattern returns an error from `glob_util::compile_glob`.\n- Search root must resolve to an existing directory for directory discovery flows.\n- Cancellation/timeouts propagate as abort errors via `CancelToken::heartbeat()` checks in loops.\n\n### Malformed glob handling\n\n`glob_util::build_glob_pattern` is tolerant:\n\n- normalizes `\\` to `/`,\n- auto-prefixes simple recursive patterns with `**/` when `recursive=true`,\n- auto-closes unbalanced `{...` alternation groups before compile.\n\n## 3) AST search/edit (`astGrep`, `astEdit`)\n\n`ast.rs` exposes syntax-aware code search and rewrite operations.\n\n- `astGrep(options)` returns matches with byte/line/column coordinates and optional metavariable bindings.\n- `astEdit(options)` returns replacement changes, per-file counts, searched/touched file counts, parse errors, and whether edits were applied.\n- `dryRun` defaults to true for edit options in the generated documentation.\n- Options include language override, path/glob/selector, strictness, limits, parse-error policy, `signal`, and `timeoutMs`.\n\nThese exports are direct native APIs used by tooling; they are not mediated by a TS wrapper in `packages/natives`.\n\n## 4) Shared scan/cache lifecycle (`fs_cache`)\n\n`fs_cache` stores scan results as normalized relative entries (`path`, `fileType`, optional `mtime`) keyed by:\n\n- canonical search root,\n- `include_hidden`,\n- `use_gitignore`.\n\n### Cache state transitions\n\n1. **Miss / disabled**\n - TTL is `0` or key absent/expired -> fresh collection.\n2. **Hit**\n - Entry age is within TTL -> return cached entries + `cache_age_ms`.\n3. **Stale-empty recheck**\n - If query yields zero matches and cache age exceeds the empty-result threshold, force one rescan.\n4. **Invalidation**\n - `invalidateFsScanCache(path?)`:\n - no arg: clear all keys;\n - path arg: remove keys for roots affected by that path.\n\n### Stale-result tradeoff\n\n- Cache favors low-latency repeated scans over immediate consistency.\n- TTL window can return stale positives/negatives.\n- Empty-result recheck reduces stale negatives for older cached scans at the cost of one extra scan.\n- Explicit invalidation is the intended correctness hook after file mutations.\n\n## 5) ANSI text utilities (`text`)\n\nThese are pure, in-memory utilities.\n\n### Boundaries and responsibilities\n\n- `text.rs` owns terminal-cell semantics:\n - ANSI sequence parsing,\n - grapheme-aware width and slicing,\n - wrap/truncate/sanitize behavior,\n - explicit tab-width parameter on width-sensitive APIs.\n- `grep.rs` line truncation (`maxColumns`) is separate:\n - simple character-boundary truncation of matched lines with `...`,\n - not ANSI-state-preserving and not terminal-cell width aware.\n\n### Key behaviors\n\n- `wrapTextWithAnsi`: wraps by visible width, carries active SGR codes across wrapped lines.\n- `truncateToWidth`: visible-cell truncation with ellipsis policy (`Unicode`, `Ascii`, `Omit`), optional right padding.\n- `sliceWithWidth`: column slicing with optional strict width enforcement.\n- `extractSegments`: extracts before/after segments around an overlay while restoring ANSI state for the `after` segment.\n- `sanitizeText` (ANSI/control/surrogate stripping with line-ending normalization) no longer lives in `text.rs`; it moved to `@gajae-code/utils` as a pure-JS implementation in `packages/utils/src/sanitize-text.ts`. The native binding was removed in the same change because the JS version was competitive on the benchmarked workloads, and keeping a Rust copy forced every caller (including `pi-utils`) to pull in `@gajae-code/natives`.\n- `visibleWidth`: counts visible terminal cells using caller-supplied tab width.\n\n### Failure behavior\n\nText functions generally return deterministic transformed output; errors are limited to N-API argument/string conversion boundaries.\n\n## 6) Syntax highlighting (`highlight`)\n\n`highlight.rs` is pure transformation; it does not use the filesystem scan cache.\n\n### Flow\n\n1. Caller passes `code`, optional `lang`, and ANSI color palette.\n2. Rust resolves syntax by token/name lookup, extension lookup, alias table fallback, then plain-text fallback.\n3. Each line is parsed with syntect `ParseState` and scope stack.\n4. Scopes map to semantic color categories and ANSI color codes are injected/reset.\n\n### Failure behavior\n\n- Per-line parse failure does not fail the call: that line is appended unhighlighted and processing continues.\n- Unknown/unsupported language falls back to plain text syntax.\n\n## 7) Token counting (`tokens`)\n\n`countTokens(input, encoding?)` is an in-memory utility.\n\n- `input` may be a single string or an array of strings.\n- Arrays return one aggregate count and are encoded in parallel in Rust.\n- Default encoding is `O200kBase`; `Cl100kBase` remains available as a compatibility alias routing to `o200k_base` in default builds.\n- The implementation uses ordinary tokenization, not special-token handling.\n\n## Pure utility vs filesystem-dependent flows\n\n| Flow | Filesystem access | Shared cache | Notes |\n| ---------------------------- | ----------------- | -------------------- | --------------------------------------------- |\n| `search` / `hasMatch` | No | No | regex on provided bytes/string only |\n| `text` module functions | No | No | ANSI/width/sanitization only |\n| `highlight` module functions | No | No | syntax + ANSI coloring only |\n| `countTokens` | No | No | tokenization only |\n| `astGrep` / `astEdit` | Yes | No | syntax-aware file search/edit |\n| `glob` | Yes | Optional | directory scans + glob filtering |\n| `fuzzyFind` | Yes | Optional | directory scans + fuzzy scoring |\n| `grep` (file/dir path) | Yes | Optional in dir mode | ripgrep over files, optional filters/callback |\n\n## End-to-end lifecycle summary\n\n1. Caller invokes generated native export with typed options.\n2. Rust validates/normalizes options and builds matcher/search config.\n3. For filesystem flows, entries are scanned (cache hit/miss/rescan where applicable) then filtered/scored/searched.\n4. Worker loops periodically call cancel heartbeat; timeout/abort can terminate execution.\n5. Rust shapes outputs into N-API objects (`lineNumber`, `matchCount`, `limitReached`, etc.).\n6. Generated bindings return typed JS objects and optional per-match callbacks for `grep`/`glob`.\n",
|
|
46
46
|
"non-compaction-retry-policy.md": "# Non-compaction auto-retry policy\n\nThis document describes the standard API-error retry path in `AgentSession`.\n\nIt explicitly excludes context-overflow recovery via auto-compaction. Overflow is handled by compaction logic and is documented separately in [`compaction.md`](../docs/compaction.md).\n\n## Implementation files\n\n- [`../src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)\n- [`../src/config/settings-schema.ts`](../packages/coding-agent/src/config/settings-schema.ts)\n- [`../src/modes/controllers/event-controller.ts`](../packages/coding-agent/src/modes/controllers/event-controller.ts)\n- [`../src/modes/rpc/rpc-mode.ts`](../packages/coding-agent/src/modes/rpc/rpc-mode.ts)\n- [`../src/modes/rpc/rpc-client.ts`](../packages/coding-agent/src/modes/rpc/rpc-client.ts)\n- [`../src/modes/rpc/rpc-types.ts`](../packages/coding-agent/src/modes/rpc/rpc-types.ts)\n\n## Scope boundary vs compaction\n\nRetry and compaction are checked from the same `agent_end` path, but they are intentionally separated:\n\n1. `agent_end` inspects the last assistant message.\n2. `#isRetryableError(...)` runs first.\n3. If retry is initiated, compaction checks are skipped for that turn.\n4. Context-overflow errors are hard-excluded from retry classification (`isContextOverflow(...)` short-circuits retry).\n5. Overflow therefore falls through to `#checkCompaction(...)` instead of standard retry.\n\nSo: overload/rate/server/network-style failures use this retry policy; context-window overflow uses compaction recovery.\n\n## Retry classification\n\n`#isRetryableError(...)` requires all of the following:\n\n- assistant `stopReason === \"error\"`\n- `errorMessage` exists\n- message is **not** context overflow\n- `errorMessage` matches transient transport/envelope patterns or `isUsageLimitError(...)`\n\nCurrent retryable inputs are regex/string-classified:\n\n- transient transport/envelope failures, including Anthropic stream-envelope failures before `message_start`\n- overloaded/provider-returned-error wording\n- rate limit / usage limit / too many requests\n- HTTP-like server classes: 429, 500, 502, 503, 504\n- service unavailable / server/internal error\n- provider-suggested retry wording, including OpenAI `retry your request` failures\n- network/connection/socket failures, refused/closed connections, upstream connect/reset-before-headers, socket hang up, timeout/timed out, fetch failed, terminated, retry delay wording, and unexpected socket close messages\n\nThis is string-pattern classification, not typed provider error codes.\n\n## Retry lifecycle and state transitions\n\nSession state used by retry:\n\n- `#retryAttempt: number` (`0` means idle)\n- `#retryPromise: Promise<void> | undefined` (tracks in-progress retry lifecycle)\n- `#retryResolve: (() => void) | undefined` (resolves `#retryPromise`)\n- `#retryAbortController: AbortController | undefined` (cancels backoff sleep)\n\nFlow (`#handleRetryableError`):\n\n1. Read `retry` settings group.\n2. If `retry.enabled === false`, stop immediately (`false`, no retry started).\n3. Increment `#retryAttempt`.\n4. Create `#retryPromise` once (first attempt in a chain).\n5. If attempt exceeded `retry.maxRetries`, emit final failure event and stop.\n6. Compute base delay: `retry.baseDelayMs * 2^(attempt-1)`.\n7. For usage-limit errors, parse retry hints and call auth storage (`markUsageLimitReached(...)`); if credential switching succeeds, force delay to `0`, otherwise use a larger retry-after/backoff hint when present.\n8. If no credential switch occurred, suppress the current model selector for cooldown, try configured retry model fallback chains, and force delay to `0` on model switch.\n9. Emit `auto_retry_start`.\n10. Remove the trailing assistant error message from agent runtime state (kept in persisted session history).\n11. Sleep with abort support.\n12. Schedule `agent.continue()` through the post-prompt task scheduler (`delayMs: 1`) for the same prompt generation.\n\n### What resets retry counters\n\n`#retryAttempt` resets to `0` in these cases:\n\n- first successful non-error, non-aborted assistant message after retries started (emits `auto_retry_end { success: true }`)\n- retry cancellation during backoff sleep\n- max retries exceeded path\n\n`#retryPromise` resolves/clears when retry chain ends (success, cancellation, or max-exceeded), via `#resolveRetry()`.\n\n## Backoff and max-attempt semantics\n\nSettings:\n\n- `retry.enabled` (default `true`)\n- `retry.maxRetries` (default `3`)\n- `retry.baseDelayMs` (default `2000`)\n- `retry.maxDelayMs` (default `300000`)\n- `retry.requestMaxRetries` (default `5`) — provider request retries before a stream is established; counts retries, not the initial request\n- `retry.streamMaxRetries` (default `5`) — provider stream replay retries for replay-safe transient stream failures; counts retries, not the initial stream attempt\n\nAttempt numbering:\n\n- attempt counter is incremented before max-check\n- start events use current attempt (1-based)\n- max-exceeded end event reports `attempt: this.#retryAttempt - 1` (last attempted retry count)\n\nBackoff sequence with default settings:\n\n- attempt 1: 2000 ms\n- attempt 2: 4000 ms\n- attempt 3: 8000 ms\n\nDelay override inputs can come from parsed retry headers (`retry-after-ms`, `retry-after`, `x-ratelimit-reset-ms`, `x-ratelimit-reset`) or usage-limit backoff. Credential/model fallback switches set delay to `0`; otherwise parsed hints can extend the exponential local delay.\n\n## Abort mechanics\n\n### Explicit retry abort\n\n`abortRetry()`:\n\n- aborts `#retryAbortController` (if present)\n- resolves retry promise (`#resolveRetry()`) so awaiters are unblocked\n\nIf abort hits while sleeping, catch path emits:\n\n- `auto_retry_end { success: false, finalError: \"Retry cancelled\" }`\n- resets attempt/controller\n\n### Global operation abort interaction\n\n`abort()` calls `abortRetry()` before aborting the active agent stream. This guarantees retry backoff is cancelled when user issues a general abort.\n\n### TUI interaction\n\nOn `auto_retry_start`, EventController:\n\n- swaps `Esc` handler to `session.abortRetry()`\n- renders loader text: `Retrying (attempt/maxAttempts) in Ns… (esc to cancel)`\n\nOn `auto_retry_end`, it restores prior `Esc` handler and clears loader state.\n\n## Streaming and prompt completion behavior\n\n`prompt()` ultimately waits on `#waitForRetry()` after `agent.prompt(...)` returns.\n\nEffect:\n\n- a prompt call does not fully resolve until any started retry chain finishes (success/failure/cancel)\n- retry lifecycle is part of one logical prompt execution boundary\n\nThis prevents callers from treating a retrying turn as complete too early.\n\n## Controls: settings and RPC\n\n### Configuration knobs\n\nDefined in settings schema under retry group:\n\n- `retry.enabled`\n- `retry.maxRetries`\n- `retry.baseDelayMs`\n- `retry.fallbackChains`\n- `retry.fallbackRevertPolicy` (`\"cooldown-expiry\"` by default; `\"never\"` disables automatic restoration)\n\nProgrammatic toggles in session:\n\n- `setAutoRetryEnabled(enabled)` writes `retry.enabled`\n- `autoRetryEnabled` reads `retry.enabled`\n- `isRetrying` reports whether retry lifecycle promise is active\n\n### RPC controls\n\nRPC command surface:\n\n- `set_auto_retry` → `session.setAutoRetryEnabled(command.enabled)`\n- `abort_retry` → `session.abortRetry()`\n\nClient helpers:\n\n- `RpcClient.setAutoRetry(enabled)`\n- `RpcClient.abortRetry()`\n\nBoth commands return success responses; retry progress/failure details come from streamed session events, not command response payloads.\n\n## Event emission and failure surfacing\n\nSession-level retry events:\n\n- `auto_retry_start { attempt, maxAttempts, delayMs, errorMessage }`\n- `auto_retry_end { success, attempt, finalError? }`\n- `retry_fallback_applied { from, to, role }`\n- `retry_fallback_succeeded { model, role }`\n\nPropagation:\n\n- emitted through `AgentSession.subscribe(...)`\n- forwarded to extension runner as extension events\n- in RPC mode, forwarded directly as JSON event objects (`session.subscribe(event => output(event))`)\n- in TUI, consumed by `EventController` for loader/error UI\n\nFinal failure surfacing:\n\n- On max-exceeded or cancellation, `auto_retry_end.success === false`\n- TUI shows: `Retry failed after N attempts: <finalError>`\n- Extensions/hooks receive `auto_retry_end` with same fields\n- RPC consumers receive same event object on stdout stream\n\n## Permanent stop conditions\n\nRetry stops and will not auto-continue when any of these occur:\n\n- `retry.enabled` is false\n- error is not retry-classified\n- error is context overflow (delegated to compaction path)\n- max retries exceeded\n- user cancels retry (`abort_retry` or `Esc` during retry loader)\n- global abort (`abort`) cancels retry first\n\nA new retry chain can still start later on a future retryable error after counters reset.\n\n## Operational caveats\n\n- Classification is regex text matching; provider-specific structured errors are not used here.\n- Retry strips the failing assistant error from **runtime context** before re-continue, but session history still keeps that error entry.\n- `RpcSessionState` currently exposes `autoCompactionEnabled` but not an `autoRetryEnabled` field; RPC callers must track their own toggle state or query settings through other APIs.\n- Model fallback changes append temporary `model_change` entries and may later restore the primary model when its cooldown expires, depending on `retry.fallbackRevertPolicy`.\n\n## Provider request/stream retry budgets\n\nThe provider budgets are deliberately separate from session auto-retry:\n\n```yaml\nretry:\n requestMaxRetries: 4\n streamMaxRetries: 100\n```\n\n`requestMaxRetries` maps to provider SDK/fetch retry counts for request setup failures such as retryable 5xx/408/429/network errors. `streamMaxRetries` maps to provider-specific stream replay loops that are safe to repeat without duplicating visible assistant output. Providers that cannot safely replay a stream continue to surface the terminal error so the session-level auto-retry layer can decide whether to retry the turn.\n\nFail-fast cases stay fail-fast: invalid credentials (after any credential-refresh path is exhausted), unsupported model/provider configuration, malformed requests, context overflow, explicit user aborts, and permanent quota failures are not treated as transient provider budget candidates.\n",
|
|
47
47
|
"notebook-tool-runtime.md": "# Notebook tool runtime internals\n\nThis document describes the current `notebook` tool implementation and its relationship to the kernel-backed Python runtime.\n\nThe critical distinction: **`notebook` is a JSON notebook editor, not a notebook executor**. It edits `.ipynb` cell sources directly; it does not start or talk to a Python kernel.\n\n## Implementation files\n\n- [`src/tools/notebook.ts`](../packages/coding-agent/src/tools/notebook.ts)\n- [`src/eval/py/executor.ts`](../packages/coding-agent/src/eval/py/executor.ts)\n- [`src/eval/py/kernel.ts`](../packages/coding-agent/src/eval/py/kernel.ts)\n- [`src/session/streaming-output.ts`](../packages/coding-agent/src/session/streaming-output.ts)\n- [`src/tools/eval.ts`](../packages/coding-agent/src/tools/eval.ts)\n\n## 1) Runtime boundary: editing vs executing\n\n## `notebook` tool (`src/tools/notebook.ts`)\n\n- Supports `action: edit | insert | delete` on a `.ipynb` file.\n- Resolves path relative to session CWD (`resolveToCwd`).\n- Loads notebook JSON, validates `cells` array, validates `cell_index` bounds.\n- Applies source edits in-memory and writes full notebook JSON back with `JSON.stringify(notebook, null, 1)`.\n- Returns textual summary + structured `details` (`action`, `cellIndex`, `cellType`, `totalCells`, `cellSource`).\n\nNo kernel lifecycle exists in this tool:\n\n- no gateway acquisition\n- no kernel session ID\n- no `execute_request`\n- no stream chunks from kernel channels\n- no rich display capture (`image/png`, JSON display, status MIME)\n\n## Notebook-like execution path (`src/tools/eval.ts` + `src/eval/py/*`)\n\nWhen the agent needs to run cell-style Python code (sequential cells, persistent state, rich displays), that goes through the **`eval` tool** with `language: \"python\"`, not `notebook`.\n\nThat path is where kernel modes, restart/cancel behavior, chunk streaming, and output artifact truncation live.\n\n## 2) Notebook cell handling semantics (`notebook` tool)\n\n## Source normalization\n\n`content` is split into `source: string[]` with newline preservation:\n\n- each non-final line keeps trailing `\\n`\n- final line has no forced trailing newline\n\nThis mirrors notebook JSON conventions and avoids accidental line concatenation on later edits.\n\n## Action behavior\n\n- `edit`\n - replaces `cells[cell_index].source`\n - preserves existing `cell_type`\n- `insert`\n - inserts at `[0..cellCount]`\n - `cell_type` defaults to `code`\n - code cells initialize `execution_count: null` and `outputs: []`\n - markdown cells initialize only `metadata` + `source`\n- `delete`\n - removes `cells[cell_index]`\n - returns removed `source` in details for renderer preview\n\n## Error surfaces\n\nHard failures are thrown for:\n\n- missing notebook file\n- invalid JSON\n- missing/non-array `cells`\n- out-of-range index (insert and non-insert have different valid ranges)\n- missing `content` for `edit`/`insert`\n\nThese become `Error:` tool responses upstream; renderer uses notebook path + formatted error text.\n\n## 3) Kernel session semantics (where they actually exist)\n\nKernel semantics are implemented in `executePython` / `PythonKernel` and apply to the Python backend of the `eval` tool.\n\n## Modes\n\n`PythonKernelMode`:\n\n- `session` (default)\n - kernels cached in `kernelSessions` map\n - max 4 sessions; oldest evicted on overflow\n - idle/dead cleanup every 30s, timeout after 5 minutes\n - per-session queue serializes execution (`session.queue`)\n- `per-call`\n - creates kernel for request\n - executes\n - always shuts down kernel in `finally`\n\n## Reset behavior\n\n`eval` passes `reset` only for the first cell in a multi-cell Python call; later cells always run with `reset: false`.\n\n## Kernel death / restart / retry\n\nIn session mode (`withKernelSession`):\n\n- dead kernel detected by heartbeat (`kernel.isAlive()` check every 5s) or execute failure.\n- pre-run dead state triggers `restartKernelSession`.\n- execute-time crash path retries once: restart kernel, rerun handler.\n- `restartCount > 1` in same session throws `Python kernel restarted too many times in this session`.\n\nStartup retry behavior:\n\n- shared gateway kernel creation retries once on `SharedGatewayCreateError` with HTTP 5xx.\n\nResource exhaustion recovery:\n\n- detects `EMFILE`/`ENFILE`/\"Too many open files\" style failures\n- clears tracked sessions\n- calls `shutdownSharedGateway()`\n- retries kernel session creation once\n\n## 4) Environment/session variable injection\n\nKernel startup receives the optional session file path from executor:\n\n- `GJC_SESSION_FILE` (session state file path)\n\n`PythonKernel.#initializeKernelEnvironment(...)` then runs init script inside kernel to:\n\n- `os.chdir(cwd)`\n- inject env entries into `os.environ`\n- prepend cwd to `sys.path` if missing\n\nImplication:\n\n- prelude helpers that read session context rely on this env var in Python process state.\n\n## 5) Streaming/chunk and display handling (kernel-backed path)\n\nThe kernel client processes Jupyter protocol messages per execution:\n\n- `stream` -> text chunk to `onChunk`\n- `execute_result` / `display_data` ->\n - display text chosen by MIME precedence: `text/markdown` > `text/plain` > converted `text/html`\n - structured outputs captured separately:\n - `application/json` -> `{ type: \"json\" }`\n - `image/png` -> `{ type: \"image\" }`\n - `application/x-gjc-status` -> `{ type: \"status\" }` (no text emission)\n- `error` -> traceback text pushed to chunk stream + structured error metadata\n- `input_request` -> emits stdin warning text, sends empty `input_reply`, marks stdin requested\n- completion waits for both `execute_reply` and kernel `status=idle`\n\nCancellation/timeout:\n\n- abort signal triggers `interrupt()` (REST `/interrupt` + control-channel `interrupt_request`)\n- result marks `cancelled=true`\n- timeout path annotates output with `Command timed out after <n> seconds`\n\n## 6) Truncation and artifact behavior\n\n`OutputSink` in `src/session/streaming-output.ts` is used by kernel execution paths (`executeWithKernel`):\n\n- sanitizes every chunk (`sanitizeText`)\n- tracks total/output lines and bytes\n- optional artifact spill file (`artifactPath`, `artifactId`)\n- when in-memory buffer exceeds threshold (`DEFAULT_MAX_BYTES` unless overridden):\n - marks truncated\n - keeps tail bytes in memory (UTF-8 safe boundary)\n - can spill full stream to artifact sink\n\n`dump()` returns:\n\n- visible output text (possibly tail-truncated)\n- truncation flag + counts\n- artifact ID (for `artifact://<id>` references)\n\n`eval` converts this metadata into result truncation notices and TUI warnings.\n\n`notebook` tool does **not** use `OutputSink`; it has no stream/artifact truncation pipeline because it does not execute code.\n\n## 7) Renderer assumptions and formatting\n\n## Notebook renderer (`notebookToolRenderer`)\n\n- call view: status line with action + notebook path + cell/type metadata\n- result view:\n - success summary derived from `details`\n - `cellSource` rendered via `renderCodeCell`\n - markdown cells set language hint `markdown`; other cells have no explicit language override\n - collapsed code preview limit is `PREVIEW_LIMITS.COLLAPSED_LINES * 2`\n - supports expanded mode via shared render options\n - uses render cache keyed by width + expanded state\n\nError rendering assumption:\n\n- if first text content starts with `Error:`, renderer formats as notebook error block.\n\n## Python renderer (for actual execution output)\n\nKernel-backed execution rendering expects:\n\n- per-cell status transitions (`pending/running/complete/error`)\n- optional structured status event section\n- optional JSON output trees\n- truncation warnings + optional `artifact://<id>` pointer\n\nThis renderer behavior is unrelated to `notebook` JSON editing results except that both reuse shared TUI primitives.\n\n## 8) Divergence from eval Python backend behavior\n\nIf \"plain Python execution\" means the `eval` tool with `language: \"python\"`:\n\n- `eval` executes code in a kernel, persists state by mode, streams chunks, captures rich displays, handles interrupts/timeouts, and supports output truncation/artifacts.\n- `notebook` performs deterministic notebook JSON mutations only; no execution, no kernel state, no chunk stream, no display outputs, no artifact pipeline.\n\nIf a workflow needs both:\n\n1. edit notebook source with `notebook`\n2. execute code cells via `eval` with `language: \"python\"` (manually passing code), not through `notebook`\n\nCurrent implementation does not provide a single tool that both mutates `.ipynb` and executes notebook cells through kernel context.\n",
|
|
48
|
-
"notifications-sdk.md": "# Notifications SDK\n\n<p align=\"center\">\n <img src=\"../assets/telegram-mobile-hero.png\" alt=\"Gajae Code 0.7.0 mobile answers for coding agents hero illustration\" width=\"100%\" />\n</p>\n\nA small, transport-agnostic way to get **action-needed** signals out of a GJC\nsession and deliver **replies** back — without scraping the terminal and without\nthe depth of the RPC / Coordinator / Bridge surfaces.\n\nThe stable contract is deliberately generic: every running session exposes one\nloopback WebSocket endpoint, and integrations are user-written clients that\nconnect to that endpoint. Telegram, Discord, Slack, mobile apps, and local tools\nall use the same JSON protocol. No upstream Rust, N-API, or wire-protocol change\nis required for a new integration.\n\n> Status: the Rust core (`crates/gjc-notifications`) provides the wire protocol,\n> action lifecycle, loopback WebSocket server, and endpoint discovery file. The\n> bundled Telegram daemon is a reference client layered on top of this SDK; it is\n> not the upstream topology.\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1:<port> (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per session.** Each session runs its own loopback WebSocket\n server. Upstream does not maintain a shared daemon, singleton, or\n chat-to-session registry; multiplexing many sessions into one integration is a\n client-side concern.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-notifications` or the JSON protocol.\n- **Off unless configured.** No endpoint exists unless notifications are enabled\n and a token is present.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n<repo>/.gjc/state/notifications/<sessionId>.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"<per-session token>\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:<port>/?token=<token>\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\n\n### Server → client\n\n`action_needed` — something needs attention:\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"wg_run_stage_1\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"question\": \"Proceed?\", \"options\": [\"Yes\", \"No\"] }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"idle-sess-1-7\", \"kind\": \"idle\",\n \"sessionId\": \"sess-1\", \"summary\": \"finished refactor; awaiting next step\" }\n```\n\n- `kind: \"ask\"` is answerable in both interactive/TUI and unattended/RPC modes.\n The `id` is the real workflow-gate id.\n- `kind: \"idle\"` is notify-only and ephemeral (not replayed to clients that\n connect later).\n\n`action_resolved` — a pending action is now terminal and **non-repliable**:\n\n```json\n{ \"type\": \"action_resolved\", \"id\": \"wg_run_stage_1\", \"resolvedBy\": \"local\" }\n```\n\n`resolvedBy` is `local` (answered in the CLI/TUI), `client` (a remote reply won),\nor `timeout`.\n\n`reply_rejected` — sent only to the client whose reply failed:\n\n```json\n{ \"type\": \"reply_rejected\", \"id\": \"wg_run_stage_1\", \"reason\": \"already_answered\" }\n```\n\nReasons: `already_answered`, `unknown_action`, `invalid_answer`,\n`resolver_unavailable`, `idempotency_conflict`, `unauthorized`.\n\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"wg_run_stage_1\", \"answer\": 0, \"token\": \"<token>\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\n\n## Answer semantics\n\nA remote reply answers a pending ask in **both** modes — RPC is not required:\n\n- **Interactive / TUI mode:** the ask tool races the local selector against the\n remote reply (first valid answer wins). If you tap a button in the client, the\n ask resolves with that option; if you answer locally, the client receives\n `action_resolved` (`resolvedBy: \"local\"`) and the action becomes non-repliable.\n- **Unattended / RPC mode:** the reply resolves the real workflow-gate, driving\n the session the same way a local answer would.\n\nIn both modes the first valid reply wins; later replies get `already_answered`.\nIdle pings are notify-only.\n\n## Minimal client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/notifications/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\n\n## Managed notification adapters\n\nFor the exact user setup flow (`gjc notify setup`, BotFather token, private-chat pairing, status, and troubleshooting), see [Telegram notification onboarding](./telegram-onboarding.md).\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\n### Setup and auto-connect\n\nRun the setup command once:\n\n```sh\ngjc notify setup\n```\n\nThe wizard validates the bot token with Telegram, verifies private-chat Threaded\nMode capability via `getMe.has_topics_enabled`, waits for a private DM to the bot,\nand writes canonical global Settings under `config.yml` in the GJC agent\ndirectory. It enables:\n\n- `notifications.enabled`\n- `notifications.telegram.botToken`\n- `notifications.telegram.chatId`\n- `notifications.redact` (optional; default false)\n- `notifications.discord.botToken` / `notifications.discord.channelId` (optional Discord adapter)\n- `notifications.slack.botToken` / `notifications.slack.channelId` (optional Slack adapter)\n\nAfter setup, sessions auto-connect when notifications are enabled. Each session\nstill publishes its own loopback endpoint; the daemon is only the Telegram-side\nmultiplexer.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\n\n### Singleton poller and trust model\n\nTelegram `getUpdates` allows only one active long-poll owner per bot token. The\nmanaged daemon enforces **one bot token = one getUpdates poller** with a local\nlock/state file under the agent directory. New sessions attach to the existing\nfresh daemon owner instead of starting another poller, preventing Telegram 409\nconflicts.\n\nThe trust model is intentionally strict:\n\n- setup pairs exactly one private Telegram chat;\n- runtime accepts updates only from that paired chat id;\n- groups, supergroups, channels, and unpaired users never receive session names,\n action ids, pending status, or configuration hints;\n- daemon state stores a token fingerprint, not the raw bot token.\n\n### Routing in private-chat topics\n\nThe paired private chat prefers per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\nGJC cannot enable Threaded Mode through the Bot API; setup only verifies the\ncapability and guides the manual BotFather toggle.\n\nIf BotFather's per-bot **Bot Settings** menu does not show **Threads Settings**\nor **Threaded Mode**, the supported fallback is the normal private-chat pairing.\nSetup can be saved as `threaded=unverified`/`threaded=unknown`, and the daemon\nstill tries topics when Telegram allows them. When `createForumTopic` is refused,\nthe daemon does not drop the send: it routes the notification to the normal\n(flat) paired private chat and posts a one-time `turn on threaded mode from\nbotfather miniapp to receive gjc notification!` nudge. Pairing is private-only,\nso flat delivery stays within the user's own private DM.\n\nSupported reply paths:\n\n- tap an inline button on an ask notification;\n- reply inside the session's thread/topic (replies are thread-native; the\n topic identifies the session, so no session tag is needed).\n\nIn threaded mode the user can also adjust per-session behaviour with in-thread\nconfig commands: `/verbose`, `/lean`, `/verbosity <lean|verbose>`, and\n`/redact <on|off>`. The legacy `/answer <session-tag> <answer>` command is\nremoved — replies are routed by the topic they arrive in.\n\nFlat fallback keeps outbound notifications and inline-button answers working,\nbut free-text replies and `/verbose`/`/lean`/`/verbosity`/`/redact` commands are\nthread-native and require topic routing. Do not pair a group, supergroup, or\nchannel to work around a missing BotFather menu; the bundled setup flow is\nprivate-chat only, and non-private chat ids remain fail-closed to avoid session\ndata leaks.\n\nUnknown, expired, or restart-unvalidated callback aliases fail closed: the daemon\nsends guidance and does not guess a target session or action.\n\n### Discord and Slack setup\n\nDiscord and Slack use the same internal notification events and reply protocol as\nTelegram. Store only runtime credentials in local GJC settings or environment;\nnever paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs\ninto docs, tests, issues, or PR comments.\n\nConfiguration keys:\n\n```yaml\nnotifications:\n enabled: true\n discord:\n botToken: \"<local Discord bot token>\"\n channelId: \"<Discord channel id>\"\n slack:\n botToken: \"<local Slack bot token>\"\n channelId: \"<Slack channel id>\"\n redact: true\n```\n\nThe bundled adapters intentionally render public-safe message bodies and return\nroute metadata only for pending internal actions. They do not own polling,\nsession scans, daemon locks, rate limits, or SDK lifecycle. Production transport\nsenders should consume the adapter payloads and keep all credential-bearing HTTP\nor gateway details outside logged payloads.\n### Redaction\n\n`notifications.redact` strips sensitive content before remote delivery, but\n**asks are exempt**: an ask is an interactive prompt the human must read and\nanswer remotely, so its `question` and `options` are always sent unredacted\n(otherwise it would be unanswerable). When redaction is enabled, `idle`\nsummaries are removed and streamed content frames (`turn_stream`,\n`context_update`, `image_attachment`) are suppressed at their emit sites. When\nredaction is disabled, all content is delivered unchanged.\n\n### Local `/notify`\n\nInside a GJC session, `/notify` controls the current session only:\n\n- `/notify status` reports enabled/disabled state, daemon observation when known,\n and redaction state without printing secrets;\n- `/notify off` disables the current session's notification endpoint and removes\n its discovery record without mutating global Settings;\n- `/notify on` re-enables the current session when global setup is complete and\n `GJC_NOTIFICATIONS=0` is not forcing opt-out.\n\n### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/notifications/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/notifications/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `<repo>/.gjc/state/notifications/<sessionId>.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `<agentDir>/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth:** loopback-only bind (a non-loopback bind is refused). The WebSocket\n upgrade requires `?token=<control-token>` (HTTP `401` otherwise), and every\n lifecycle frame's `token` is re-checked (`unauthorized` on mismatch). The Rust\n ingress authenticates and forwards; it never spawns or applies policy.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- <prompt>`) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- <prompt>` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\n### Phone test guide (create / close / resume from Telegram)\n\nEnd-to-end manual check once `gjc notify setup` has paired your private chat:\n\n1. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat send `/session_create path <repo-dir>` (or\n `/session_create worktree <repo> <branch>`, or `/session_create dir <newdir>`).\n The bot replies once the tmux launch is requested; the session shows up in\n `/session_recent` once it is ready. (Inline prompts via `-- <text>` are\n rejected for now with usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close <sessionId>` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume <sessionId|prefix>` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n",
|
|
48
|
+
"notifications-sdk.md": "# Notifications SDK\n\n<p align=\"center\">\n <img src=\"../assets/telegram-mobile-hero.png\" alt=\"Gajae Code 0.7.0 mobile answers for coding agents hero illustration\" width=\"100%\" />\n</p>\n\nA small, transport-agnostic way to get **action-needed** signals out of a GJC\nsession and deliver **replies** back — without scraping the terminal and without\nthe depth of the RPC / Coordinator / Bridge surfaces.\n\nThe stable contract is deliberately generic: every running session exposes one\nloopback WebSocket endpoint, and integrations are user-written clients that\nconnect to that endpoint. Telegram, Discord, Slack, mobile apps, and local tools\nall use the same JSON protocol. No upstream Rust, N-API, or wire-protocol change\nis required for a new integration.\n\n> Status: the Rust core (`crates/gjc-notifications`) provides the wire protocol,\n> action lifecycle, loopback WebSocket server, and endpoint discovery file. The\n> bundled Telegram daemon is a reference client layered on top of this SDK; it is\n> not the upstream topology.\n\n## Architecture\n\n```\nGJC session (upstream) your client (anywhere)\n┌───────────────────────────────┐ ┌──────────────────────────┐\n│ ask-tool fires / agent idle │ action_needed │ Telegram / Discord / ... │\n│ → notifications core │ ─────────────▶ │ render + collect reply │\n│ ws://127.0.0.1:<port> (+token) │ ◀───────────── │ │\n│ reply → resolve ask gate │ reply │ │\n└───────────────────────────────┘ └──────────────────────────┘\n```\n\n- **One endpoint per session.** Each session runs its own loopback WebSocket\n server. Upstream does not maintain a shared daemon, singleton, or\n chat-to-session registry; multiplexing many sessions into one integration is a\n client-side concern.\n- **Integrations are clients.** A client discovers endpoint files, connects to\n one or more WebSockets, renders `action_needed`, and sends `reply` messages.\n- **Zero upstream change.** New transports do not require changes to\n `crates/gjc-notifications` or the JSON protocol.\n- **Off unless configured.** No endpoint exists unless notifications are enabled\n and a token is present.\n- **tmux-agnostic.** The endpoint behaves identically with or without tmux.\n\n## Endpoint discovery\n\nA running session writes a discovery file at:\n\n```\n<repo>/.gjc/state/notifications/<sessionId>.json\n```\n\n(`.gjc/state/` is git-ignored.) Shape:\n\n```json\n{\n \"version\": 1,\n \"sessionId\": \"019edd41-...\",\n \"pid\": 12345,\n \"host\": \"127.0.0.1\",\n \"port\": 53124,\n \"url\": \"ws://127.0.0.1:53124\",\n \"token\": \"<per-session token>\",\n \"startedAt\": 1718760000000,\n \"updatedAt\": 1718760000000,\n \"stale\": false\n}\n```\n\n- The file is created `0700`/`0600` (unix) and written atomically.\n- The **token is in the file** because clients need it; never log it raw.\n Stale files (dead PID, past TTL, or explicitly marked) are cleaned up on the\n next start.\n\nConnect with the token as a query parameter:\n\n```\nws://127.0.0.1:<port>/?token=<token>\n```\n\nA wrong/missing token is rejected at the handshake with HTTP `401`.\n\n## Protocol\n\nJSON text frames. Field names are `camelCase`; the `type` discriminator is\n`snake_case`.\n\n### Server → client\n\n`action_needed` — something needs attention:\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"wg_run_stage_1\", \"kind\": \"ask\",\n \"sessionId\": \"sess-1\", \"question\": \"Proceed?\", \"options\": [\"Yes\", \"No\"] }\n```\n\n```json\n{ \"type\": \"action_needed\", \"id\": \"idle-sess-1-7\", \"kind\": \"idle\",\n \"sessionId\": \"sess-1\", \"summary\": \"finished refactor; awaiting next step\" }\n```\n\n- `kind: \"ask\"` is answerable in both interactive/TUI and unattended/RPC modes.\n The `id` is the real workflow-gate id.\n- `kind: \"idle\"` is notify-only and ephemeral (not replayed to clients that\n connect later).\n\n`action_resolved` — a pending action is now terminal and **non-repliable**:\n\n```json\n{ \"type\": \"action_resolved\", \"id\": \"wg_run_stage_1\", \"resolvedBy\": \"local\" }\n```\n\n`resolvedBy` is `local` (answered in the CLI/TUI), `client` (a remote reply won),\nor `timeout`.\n\n`reply_rejected` — sent only to the client whose reply failed:\n\n```json\n{ \"type\": \"reply_rejected\", \"id\": \"wg_run_stage_1\", \"reason\": \"already_answered\" }\n```\n\nReasons: `already_answered`, `unknown_action`, `invalid_answer`,\n`resolver_unavailable`, `idempotency_conflict`, `unauthorized`.\n\nThe frames above are the minimal contract every client implements. Threaded\nclients (like the managed Telegram daemon) may also receive optional\nserver → client frames they can render or ignore: `identity_header` (one-time\nper-session repo/branch/machine header), `context_update` (last message, task,\ngoal, token usage, model, diff), `turn_stream` (live/finalized turn output),\n`image_attachment` (agent-produced images), `activity` (busy/idle, drives the\ntyping indicator), `inbound_ack` (delivery state of an injected user message),\n`session_closed` (endpoint teardown; threaded clients may delete/archive the\nremote conversation), `config_update` (current verbosity/redact), `hello`\n(server capability/version), and `pong`. A minimal client only needs\n`action_needed`, `action_resolved`, and `reply_rejected`.\n\n### Client → server\n\n`reply` — answer a pending `ask`:\n\n```json\n{ \"type\": \"reply\", \"id\": \"wg_run_stage_1\", \"answer\": 0, \"token\": \"<token>\" }\n```\n\n`answer` accepts:\n\n- a number — zero-based option index (`0` = first option);\n- a string — an option label, or free text;\n- an object — `{ \"selected\": [0, \"Maybe\"], \"custom\": \"...\" }` for multi-select.\n\nOptional `idempotencyKey` makes retries safe: the same key + same body re-acks;\nthe same key + different body is rejected with `idempotency_conflict`.\n\nThreaded clients may also send optional client → server frames: `user_message`\n(inject/steer a turn with free text), `config_command` (toggle verbosity/redact\nin-thread), `hello` (capability/version), and `ping`. A minimal client only\nneeds `reply`.\n\n## Answer semantics\n\nA remote reply answers a pending ask in **both** modes — RPC is not required:\n\n- **Interactive / TUI mode:** the ask tool races the local selector against the\n remote reply (first valid answer wins). If you tap a button in the client, the\n ask resolves with that option; if you answer locally, the client receives\n `action_resolved` (`resolvedBy: \"local\"`) and the action becomes non-repliable.\n- **Unattended / RPC mode:** the reply resolves the real workflow-gate, driving\n the session the same way a local answer would.\n\nIn both modes the first valid reply wins; later replies get `already_answered`.\nIdle pings are notify-only.\n\n## Minimal client example\n\n```js\nimport { readFileSync } from \"node:fs\";\nimport WebSocket from \"ws\";\n\nconst { url, token } = JSON.parse(\n readFileSync(`.gjc/state/notifications/${sessionId}.json`, \"utf8\"),\n);\n\nconst ws = new WebSocket(`${url}/?token=${encodeURIComponent(token)}`);\n\nws.on(\"message\", (data) => {\n const msg = JSON.parse(data.toString());\n if (msg.type === \"action_needed\" && msg.kind === \"ask\") {\n // present msg.question / msg.options to the human, then:\n ws.send(JSON.stringify({ type: \"reply\", id: msg.id, answer: 0, token }));\n } else if (msg.type === \"action_resolved\") {\n // mark this action as no longer answerable in your UI\n } else if (msg.type === \"reply_rejected\") {\n // e.g. reason === \"already_answered\" → the ask was answered elsewhere\n }\n});\n```\n\nSwap `ws` for a Telegram bot's long-poll loop, a Discord gateway client, or a\nSlack socket-mode app — the contract above is all you implement.\n\n## Managed notification adapters\n\nFor the exact user setup flow (`gjc notify setup`, BotFather token, private-chat pairing, status, and troubleshooting), see [Telegram notification onboarding](./telegram-onboarding.md).\n\n## Managed Telegram daemon (bundled reference client)\n\nGJC also ships a managed Telegram reference client for the common phone-notify\nworkflow. It remains a client of the generic SDK: it scans session discovery\nfiles, opens each session WebSocket, and routes Telegram replies back to the\nmatching endpoint.\n\nThe daemon/session engine is shared. Session discovery, WebSocket protocol,\nredaction decisions, rate-limit pooling, reply routing, singleton ownership, and\nlifecycle control are not reimplemented by each chat surface. Telegram, Discord,\nand Slack adapters are thin presentation layers: they render internal notification\nevents into transport payloads and map transport interactions back to `{sessionId,\nactionId,answer}` replies.\n\n### Setup and auto-connect\n\nRun the setup command once:\n\n```sh\ngjc notify setup\n```\n\nThe wizard validates the bot token with Telegram, verifies private-chat Threaded\nMode capability via `getMe.has_topics_enabled`, waits for a private DM to the bot,\nand writes canonical global Settings under `config.yml` in the GJC agent\ndirectory. It enables:\n\n- `notifications.enabled`\n- `notifications.telegram.botToken`\n- `notifications.telegram.chatId`\n- `notifications.redact` (optional; default false)\n- `notifications.discord.botToken` / `notifications.discord.channelId` (optional Discord adapter)\n- `notifications.slack.botToken` / `notifications.slack.channelId` (optional Slack adapter)\n\nAfter setup, sessions auto-connect when notifications are enabled. Each session\nstill publishes its own loopback endpoint; the daemon is only the Telegram-side\nmultiplexer.\n\nFor Telegram forum topics, the daemon deletes the per-session topic when the local\nnotification endpoint shuts down, so it disappears from the topic list. A resumed\nsession creates a fresh topic before sending again. The bot must be allowed to\ndelete messages in that chat; without that permission, deletion is best-effort and\ndelivery continues.\n\n### Singleton poller and trust model\n\nTelegram `getUpdates` allows only one active long-poll owner per bot token. The\nmanaged daemon enforces **one bot token = one getUpdates poller** with a local\nlock/state file under the agent directory. New sessions attach to the existing\nfresh daemon owner instead of starting another poller, preventing Telegram 409\nconflicts.\n\nThe trust model is intentionally strict:\n\n- setup pairs exactly one private Telegram chat;\n- runtime accepts updates only from that paired chat id;\n- groups, supergroups, channels, and unpaired users never receive session names,\n action ids, pending status, or configuration hints;\n- daemon state stores a token fingerprint, not the raw bot token.\n\n### Routing in private-chat topics\n\nThe paired private chat prefers per-session Telegram topics (Threaded Mode). The\ndaemon tags messages by session, stores compact callback aliases for inline\nbuttons, and routes replies back to the exact session/action. A forum-enabled\nsupergroup is no longer required: when the bot owner enables Threaded Mode in\n@BotFather, the daemon creates one topic per session in the paired private chat.\nGJC cannot enable Threaded Mode through the Bot API; setup only verifies the\ncapability and guides the manual BotFather toggle.\n\nIf BotFather's per-bot **Bot Settings** menu does not show **Threads Settings**\nor **Threaded Mode**, the supported fallback is the normal private-chat pairing.\nSetup can be saved as `threaded=unverified`/`threaded=unknown`, and the daemon\nstill tries topics when Telegram allows them. When `createForumTopic` is refused,\nthe daemon does not drop the send: it routes the notification to the normal\n(flat) paired private chat and posts a one-time `turn on threaded mode from\nbotfather miniapp to receive gjc notification!` nudge. Pairing is private-only,\nso flat delivery stays within the user's own private DM.\n\nSupported reply paths:\n\n- tap an inline button on an ask notification;\n- reply inside the session's thread/topic (replies are thread-native; the\n topic identifies the session, so no session tag is needed).\n\nIn threaded mode the user can also adjust per-session behaviour with in-thread\nconfig commands: `/verbose`, `/lean`, `/verbosity <lean|verbose>`, and\n`/redact <on|off>`. The legacy `/answer <session-tag> <answer>` command is\nremoved — replies are routed by the topic they arrive in.\n\nFlat fallback keeps outbound notifications and inline-button answers working,\nbut free-text replies and `/verbose`/`/lean`/`/verbosity`/`/redact` commands are\nthread-native and require topic routing. Do not pair a group, supergroup, or\nchannel to work around a missing BotFather menu; the bundled setup flow is\nprivate-chat only, and non-private chat ids remain fail-closed to avoid session\ndata leaks.\n\nUnknown, expired, or restart-unvalidated callback aliases fail closed: the daemon\nsends guidance and does not guess a target session or action.\n\n### Discord and Slack setup\n\nDiscord and Slack use the same internal notification events and reply protocol as\nTelegram. Store only runtime credentials in local GJC settings or environment;\nnever paste bot tokens, webhook URLs, transcripts, prompts, host paths, or raw logs\ninto docs, tests, issues, or PR comments.\n\nConfiguration keys:\n\n```yaml\nnotifications:\n enabled: true\n discord:\n botToken: \"<local Discord bot token>\"\n channelId: \"<Discord channel id>\"\n slack:\n botToken: \"<local Slack bot token>\"\n channelId: \"<Slack channel id>\"\n redact: true\n```\n\nThe bundled adapters intentionally render public-safe message bodies and return\nroute metadata only for pending internal actions. They do not own polling,\nsession scans, daemon locks, rate limits, or SDK lifecycle. Production transport\nsenders should consume the adapter payloads and keep all credential-bearing HTTP\nor gateway details outside logged payloads.\n### Redaction\n\n`notifications.redact` strips sensitive content before remote delivery, but\n**asks are exempt**: an ask is an interactive prompt the human must read and\nanswer remotely, so its `question` and `options` are always sent unredacted\n(otherwise it would be unanswerable). When redaction is enabled, `idle`\nsummaries are removed and streamed content frames (`turn_stream`,\n`context_update`, `image_attachment`) are suppressed at their emit sites. When\nredaction is disabled, all content is delivered unchanged.\n\n### Local `/notify`\n\nInside a GJC session, `/notify` controls the current session only:\n\n- `/notify status` reports enabled/disabled state, daemon observation when known,\n and redaction state without printing secrets;\n- `/notify off` disables the current session's notification endpoint and removes\n its discovery record without mutating global Settings;\n- `/notify on` re-enables the current session when global setup is complete and\n `GJC_NOTIFICATIONS=0` is not forcing opt-out.\n\n### Manual Telegram CLI is for debugging\n\n`packages/coding-agent/src/notifications/telegram-cli.ts` remains as a manual\nreference/debug client and template for other integrations. It is not the primary\nTelegram UX.\n\n```sh\nbun run packages/coding-agent/src/notifications/telegram-cli.ts --bot-token \"$BOT_TOKEN\"\n```\n\nBy default it refuses to start when a fresh managed daemon already owns the same\nbot token for the same paired chat, because a second poller will cause Telegram\n409 conflicts. Use `--force` only for deliberate debugging when you have stopped\nor intentionally want to override the daemon guard.\n## Two client surfaces: per-session vs daemon-owned lifecycle control\n\nThe SDK now exposes **two distinct surfaces**. Do not confuse them:\n\n1. **Per-session notification clients (the normal, documented contract above).**\n A client discovers `<repo>/.gjc/state/notifications/<sessionId>.json`, connects\n to that session's loopback WebSocket, and handles `action_needed`,\n `action_resolved`, `reply_rejected`, and the optional threaded frames. This is\n all an ordinary integration (Telegram, Discord, Slack, mobile, local tools)\n needs. It requires **zero** upstream changes.\n\n2. **The daemon-owned session *lifecycle* control endpoint (privileged).**\n A separate, **session-independent**, loopback-only, authenticated control\n endpoint that accepts `session_create` / `session_close` / `session_resume`\n frames. It exists because creating a session cannot use a per-session socket\n (none exists before the session does). It is **not** part of the normal\n integration contract: ordinary clients never implement it. Only the bundled,\n trusted daemon (e.g. the managed Telegram daemon) speaks it.\n\n### Lifecycle control endpoint\n\n- **Discovery:** `<agentDir>/notifications/control.json` (daemon-owned, mode\n `0600`), distinct from per-session endpoint files. It carries only non-secret\n endpoint metadata (url/host/port/pid/owner). The control token is held **in\n memory** by the daemon (the sole client) and is **never** written to disk.\n- **Auth:** loopback-only bind (a non-loopback bind is refused). The WebSocket\n upgrade requires `?token=<control-token>` (HTTP `401` otherwise), and every\n lifecycle frame's `token` is re-checked (`unauthorized` on mismatch). The Rust\n ingress authenticates and forwards; it never spawns or applies policy.\n- **Frames:** `session_create` (target `existing_path` | `worktree` |\n `plain_dir`), `session_close` (hard-kill, history preserved, recoverable),\n `session_resume` (reattach if alive, else cold-restart from history); responses\n `session_create_response` / `session_close_response` / `session_resume_response`\n / `session_lifecycle_error`. The protocol also defines a replayable\n `session_ready` per-session frame for readiness-gated creates; the current MVP\n daemon replies once the tmux launch is requested (see the phone guide) rather\n than waiting on it. Inline prompt text (`-- <prompt>`) is rejected in the MVP.\n\n### Trust model and hardening (daemon side)\n\nThe control endpoint trusts the configured paired chat for any path (an accepted\nrisk). It is hardened around that boundary:\n\n- **Strict paired-chat gating** — non-paired chats are rejected *before* any path\n parsing, filesystem, or process action.\n- **Durable idempotency** — a locked, atomic, fsynced ledger keyed by\n `chatId:updateId` + request hash (`telegram-lifecycle-idempotency.json`).\n Duplicate updates never repeat side effects, including across daemon restart; a\n duplicate while in-progress reports pending (never a second spawn); a same id\n with a different body is `duplicate_conflict`; an effect failure is recorded\n `terminal_uncertain` (never auto-respawned).\n- **Per-chat create rate limit.**\n- **Audit log** — append-only `telegram-lifecycle-audit.jsonl` (`0600`) recording\n every accept/reject/duplicate/rate-limit/spawn/success/failure. Raw control\n tokens and raw prompts are never logged (prompt hash + byte length only).\n- **Inline prompts rejected (MVP)** — `session_create` with `-- <prompt>` text is\n rejected with usage; no prompt is ever placed in argv, audit, or responses. (A\n redacted prompt-ref flow is reserved for a future revision.)\n- **GJC-managed-only close** — force-close re-reads the exact `@gjc-profile`\n immediately before kill and requires the `@gjc-session-id` (and optional\n `@gjc-session-state-file`) tag to match; it never touches non-GJC tmux.\n- **Recent-activity picker** — sessions are ranked by history-file mtime and\n enriched with terminal breadcrumbs so the operator picks a recent repo/session\n instead of typing raw paths. Ambiguous resumes fail closed with candidates.\n### Phone test guide (create / close / resume from Telegram)\n\nEnd-to-end manual check once `gjc notify setup` has paired your private chat:\n\n1. **Pair + start.** Run `gjc notify setup` (BotFather token, DM the bot to pair).\n Start any GJC session with notifications enabled so the daemon owner is\n running (`gjc launch` in a repo, or `GJC_NOTIFICATIONS=1`). The owner starts\n the loopback control endpoint and accepts `/session_*` while running; with zero\n active sessions it still idle-exits after the inactivity timeout.\n2. **Create.** From your paired chat send `/session_create path <repo-dir>` (or\n `/session_create worktree <repo> <branch>`, or `/session_create dir <newdir>`).\n `<repo-dir>`, `<repo>`, and `<newdir>` may use `~`/`~/...` for your own home\n directory; named-user forms such as `~alice/repo` are rejected. The bot replies\n once the tmux launch is requested; the session shows up in `/session_recent`\n once it is ready. (Inline prompts via `-- <text>` are rejected for now with\n usage text.)\n3. **List.** `/session_recent` shows recent sessions (most-recent first) to copy\n an id from.\n4. **Close.** `/session_close <sessionId>` hard-kills the GJC-managed session\n (history is preserved); the bot confirms.\n5. **Resume.** `/session_resume <sessionId|prefix>` reattaches if it is still\n alive, otherwise cold-restarts it from saved history. An ambiguous prefix\n replies with the matching candidates instead of guessing.\n\nCommands are accepted **only** from the paired chat; **create** is rate-limited,\nand all lifecycle commands are idempotent per Telegram update id and audited (no\ntokens or prompts are logged).\nFor an automated proof of the wire path without a real bot, see\n`packages/coding-agent/scripts/g011-daemon-path-smoke.ts` (real native control\nendpoint + loopback WebSocket).\n",
|
|
49
49
|
"onboarding-packet.md": "# Gajae-Code Onboarding Packet\n\nThis packet is a docs-only, public-safe context seed for the `gajae-code` repository as inspected on 2026-06-01. It is intentionally a no-new-skill artifact: not a new workflow skill, command, agent, configuration surface, issue template, or runtime behavior.\n\n## Purpose in one paragraph\n\nGajae-Code is the `gjc` coding-agent CLI and supporting monorepo. The product centers on a small public workflow loop: clarify with `deep-interview`, plan with `ralplan`, execute and verify through `ultragoal`, and use `team` only when parallel tmux workers are useful. The main product package is `packages/coding-agent/`; supporting packages provide LLM/provider access, agent runtime, TUI rendering, native helpers, stats, utilities, benchmarks, and Python RPC/bot integrations.\n\n## Fixed public surface\n\nKeep this invariant front-and-center when onboarding to the repo:\n\n- Default workflow skills: `deep-interview`, `ralplan`, `team`, `ultragoal`.\n- Public role agents: `executor`, `architect`, `planner`, `critic`.\n- Bundled default workflow skill sources live under `packages/coding-agent/src/defaults/gjc/skills/`.\n- Bundled role-agent prompt sources live under `packages/coding-agent/src/prompts/agents/`.\n- Runtime state, specs, plans, goals, team state, and local overrides belong under `.gjc/` for the product and `.omx/` only for this agent-run orchestration.\n\nDo not add a fifth default skill, fifth public role agent, new command, new config surface, or feature-intake behavior unless that product decision has already been made and the default-surface gates are updated.\n\n## Primary entrypoints\n\n| Area | Repo-relative path | Why it matters |\n| ---------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |\n| CLI bootstrap | `packages/coding-agent/src/cli.ts` | Registers top-level CLI commands and routes default launch behavior. |\n| Session launch | `packages/coding-agent/src/main.ts` | Converts CLI/runtime settings into agent-session creation and mode dispatch. |\n| Agent assembly | `packages/coding-agent/src/sdk.ts` | Loads settings, default skills, rules, tools, auth/model state, system prompt, and agent runtime. |\n| Built-in tools | `packages/coding-agent/src/tools/index.ts` | Registers file, shell, edit, search, browser, task/subagent, and related public coding-harness tools. Memory backends are private integrations, not public tools. |\n| Default skills | `packages/coding-agent/src/defaults/gjc-defaults.ts` | Embeds and installs the four default workflow skills plus deep-interview fragments. |\n| Role agents | `packages/coding-agent/src/task/agents.ts` | Embeds bundled task-agent prompts; tests enforce public role-agent expectations. |\n| Product overview | `README.md` | Explains installation, product story, fixed workflow surface, and development entry commands. |\n| Architecture map | `docs/codebase-overview.md` | Public package map and runtime-flow reference. |\n\n## Package map\n\n- `packages/coding-agent/` — main `gjc` CLI, workflows, session runtime, tool registry, discovery, settings, prompts, and tests.\n- `packages/ai/` — provider/model boundary, streaming, auth, model registry, retries, and provider integrations.\n- `packages/agent/` — stateful agent loop and append-only context runtime.\n- `packages/tui/` — terminal UI framework and rendering primitives.\n- `packages/natives/` plus `crates/*` — native helpers, Rust/N-API bindings, shell/PTY, text search, AST, filesystem, and media utilities.\n- `packages/utils/` — shared TypeScript utilities, logging, formatting, process helpers, JSON/frontmatter, and sanitization.\n- `packages/stats/` — local observability dashboard and session/model usage aggregation.\n- `packages/typescript-edit-benchmark/` — TypeScript edit benchmark tooling.\n- `python/gjc-rpc/` — Python client for `gjc --mode rpc`.\n- `python/robogjc/` — GitHub triage/fix bot that drives `gjc --mode rpc`; this subtree has its own `AGENTS.md`.\n\n## Build, test, and validation commands\n\nPrefer targeted checks first, then broader checks when code changes justify them. For this docs-only packet, lightweight validation is enough.\n\n| Command | Scope | When to use |\n| --------------------------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------- |\n| `bun install` | Workspace dependencies | Initial local setup. |\n| `bun run install:defaults` | Local default install | Installs source-bundled default workflow definitions for local development. |\n| `bun packages/coding-agent/src/cli.ts --help` | CLI smoke/discovery | Fast source checkout CLI sanity check. |\n| `bun run check:ts` | Type/lint/default UI checks | Broad TypeScript validation; heavier than docs-only changes. |\n| `bun run test` | Full TS + Rust tests | Broad regression check; use for runtime/product changes. |\n| `bun run ci:test:smoke` | CLI version/help/stats worker smoke | Useful before release/install changes. |\n| `bun scripts/check-visible-definitions.ts` | Default surface gate | Required after workflow-definition changes. |\n| `bun scripts/verify-g002-gates.ts` | Rebrand/default-surface gate | Required after workflow-definition or public-surface changes. |\n| `bun scripts/rebrand-inventory.ts --strict` | Rebrand inventory gate | Required after workflow-definition or public-surface changes. |\n| `bun test packages/coding-agent/test/default-gjc-definitions.test.ts` | Four-skills/four-agents contract | Required after default workflow/agent surface changes. |\n\nRepository rule: do not run `tsc` or `npx tsc`; use the Bun scripts above.\n\n## Danger zones\n\n- **Default surface expansion:** `packages/coding-agent/src/defaults/gjc/skills/`, `packages/coding-agent/src/defaults/gjc-defaults.ts`, `packages/coding-agent/src/prompts/agents/`, and model-assignment tests are contract-heavy. Changes here can accidentally alter the fixed four-skills/four-agents shape.\n- **CLI commands:** `packages/coding-agent/src/cli.ts` and `packages/coding-agent/src/commands/` define visible behavior. Adding commands or aliases is a product-surface change.\n- **Runtime/session assembly:** `packages/coding-agent/src/main.ts`, `packages/coding-agent/src/sdk.ts`, discovery, settings, tools, and system-prompt paths can affect every session.\n- **TUI/logging:** Avoid `console.log`, `console.warn`, or `console.error` inside `packages/coding-agent/`; use the centralized logger to avoid corrupting TUI rendering.\n- **Secrets/auth/config:** Keep `docs/secrets.md`, auth broker/gateway code, settings, and environment-variable docs public-safe. Do not expose tokens or private infrastructure.\n- **Native/Rust build:** `packages/natives/` and `crates/*` can require platform-specific toolchains and CI artifact behavior.\n- **Python bot subtree:** `python/robogjc/` has its own local instructions and trust boundaries.\n- **Generated model data:** Do not edit `packages/ai/src/models.json` directly; update generators/descriptors/resolvers and regenerate with `bun --cwd=packages/ai run generate-models`.\n\n## Unknowns worth preserving\n\n- Which onboarding packet shape will be most useful for future `gjc` context ingestion is still an experiment, not a product contract.\n- Public issue #158 / `gajae-deep-onboarding` context is summarized only from the user-provided prompt in this run; this packet does not add issue intake or feature workflow behavior.\n- Full CI may depend on runner/system dependencies and native artifacts; docs-only changes usually do not need the full matrix locally.\n- Some packages contain internal or hidden utility prompts/agents beyond the four public role agents. Public-facing docs should keep the four-role contract clear.\n\n## First safe tasks for a new contributor or agent\n\n1. Read `README.md`, `docs/codebase-overview.md`, and this packet.\n2. Run `bun packages/coding-agent/src/cli.ts --help` for a fast CLI surface check after dependencies are installed.\n3. For docs-only edits, run formatting/check commands that do not mutate runtime behavior.\n4. For default-surface edits, run the four required gates listed in the command table before claiming completion.\n5. For package code edits, start with the nearest package test, then escalate to `bun run check:ts` or `bun run test` as risk increases.\n6. Before changing `packages/coding-agent/src/defaults/gjc/skills/`, `packages/coding-agent/src/prompts/agents/`, `packages/coding-agent/src/commands/`, or config/settings paths, write down whether the change alters public surface area.\n\n## Context seed checklist\n\nA future agent can use this packet as context if it preserves these constraints:\n\n- Keep changes public-safe and repo-relative.\n- Prefer docs and tests over new runtime abstractions for onboarding experiments.\n- Treat the fixed four-skills/four-agents shape as a product constraint.\n- Verify claims with repo files before summarizing them.\n- Report validation evidence and caveats instead of implying hidden automation.\n",
|
|
50
50
|
"onboarding-receipt.md": "# Onboarding Packet Receipt\n\n- Date: 2026-06-01\n- Scope: docs-only no-new-skill onboarding packet experiment for this repository.\n- Output files:\n - `docs/onboarding-packet.md`\n - `docs/onboarding-receipt.md`\n- Public-safe boundary: no secrets, tokens, hidden prompts, private infrastructure, internal ops, or private paths beyond repo-relative paths.\n- Product boundary: no new skill, command, agent slot, issue, config, or runtime behavior.\n\n## Evidence inspected\n\n- `README.md`\n- `docs/codebase-overview.md`\n- `package.json`\n- `packages/coding-agent/package.json`\n- `packages/coding-agent/src/cli.ts`\n- `packages/coding-agent/src/main.ts`\n- `packages/coding-agent/src/sdk.ts`\n- `packages/coding-agent/src/defaults/gjc-defaults.ts`\n- `packages/coding-agent/src/task/agents.ts`\n- `packages/coding-agent/test/default-gjc-definitions.test.ts`\n- `.github/workflows/ci.yml`\n- `.github/workflows/dev-ci.yml`\n\n## Result\n\nThe packet records repo purpose, package layout, main entrypoints, build/test commands, danger zones, unknowns, and first safe tasks without changing the product surface. It is suitable as a public context seed for future onboarding experiments, not as a feature intake mechanism.\n\n## Caveats\n\n- The attempted `omx question --input '<json>' --json` interview round failed before user input because the runtime reported no attached tmux client; no human answer was inferred from that failed call.\n- Public issue context is limited to the user-provided prompt summary for this run.\n- Full CI was not required for the docs-only artifact unless later code/runtime files change.\n",
|
|
51
51
|
"ooo-bridge-extension-contract.md": "# Ouroboros `ooo` bridge extension contract\n\nGJC exposes the `ooo` bridge through the existing extension input-event surface. It is not a default workflow skill, hook, slash command, or built-in agent.\n\n## Interception surface\n\nExtensions register an `input` handler:\n\n```ts\nimport { createOuroborosOooBridge } from \"@gajae-code/coding-agent/extensibility/extensions\";\n\nexport default function activate(gjc) {\n gjc.on(\"input\", createOuroborosOooBridge());\n}\n```\n\nThe handler matches only the bare exact prefix:\n\n- `ooo`\n- `ooo ...`\n\nIt does not match embedded or longer-token text such as `please ooo status`, `oooo`, or `/ooo`.\n\nThe extension runner already treats `InputEventResult.handled === true` as terminal: the input is not sent through normal model flow. An empty result (`{}`) means continue/pass-through, preserving existing chained input handlers and normal prompt handling.\n\n## Dispatch and result semantics\n\n`createOuroborosOooBridge()` is a small specialization of `createExactPrefixCommandBridge()`:\n\n- command: `ouroboros`\n- arguments: `dispatch`, then the full submitted input text\n- recursion guard variable: `_OUROBOROS_GJC_BRIDGE_DEPTH`\n- continue/pass-through exit code: `78`\n\nExit-code mapping:\n\n| Dispatch result | GJC input result |\n| --- | --- |\n| `0` | `{ handled: true }`; do not send input to the model. |\n| `78` | `{}`; continue/pass-through so GJC processes the input normally. |\n| any other non-zero | Surface an extension error notification using stderr, then stdout, then a generic exit-code message, and return `{ handled: true }`; the failed `ooo` command is terminal and is not sent to the model. |\n\n## Recursion guard\n\nBefore dispatch, the helper sets `_OUROBOROS_GJC_BRIDGE_DEPTH` to the next numeric depth and restores the previous value after dispatch finishes. A current numeric depth of `0` or `1` is dispatchable, which preserves concurrent independent interactive inputs while marking child dispatcher processes with depth `1`. A current numeric depth greater than `1`, or any non-empty non-numeric value, returns `{}` without dispatching.\n\nThis means the bridge allows exactly one inherited bridge-marked dispatcher level and blocks recursive re-entry from deeper bridge-marked children. The guard also passes through `event.source === \"extension\"` to avoid extension-originated messages re-entering the bridge.\n\n## Installation and discovery\n\nThe canonical install location is the agent extensions directory discovered by the native GJC provider:\n\n- user-level: `${GJC_CODING_AGENT_DIR:-$HOME/.gjc/agent}/extensions`\n- project-level: `<cwd>/${GJC_CONFIG_DIR:-.gjc}/extensions`\n\nFor native discovery, install one of:\n\n- `extensions/<name>.ts` or `extensions/<name>.js`\n- `extensions/<name>/index.ts` or `extensions/<name>/index.js`\n- `extensions/<name>/package.json` declaring extension entries\n\nThe loader scans one level under each `extensions` directory. Complex packages should use a package manifest instead of relying on recursive discovery.\n\n`GJC_CONFIG_DIR` selects the project config directory name. `GJC_CODING_AGENT_DIR` selects the user agent directory name under `$HOME`. The native provider resolves those locations before loading extension modules, skills, rules, hooks, and related capabilities.\n\nHooks are not the input bridge surface: `packages/coding-agent/src/capability/hook.ts` defines pre/post tool hooks only.\n",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* prompt text ever enters a frame, audit, log, or response until daemon-owned
|
|
12
12
|
* 0600 prompt refs are designed.
|
|
13
13
|
*/
|
|
14
|
+
import * as os from "node:os";
|
|
14
15
|
import type { SessionCloseTarget, SessionCreateTarget, SessionLifecycleResponse, SessionResumeTarget } from "./index";
|
|
15
16
|
|
|
16
17
|
export type LifecycleCommandVerb = "session_create" | "session_close" | "session_resume";
|
|
@@ -94,22 +95,21 @@ export function parseLifecycleCommand(text: string | undefined): ParsedLifecycle
|
|
|
94
95
|
const kind = args[0];
|
|
95
96
|
if (kind === "path") {
|
|
96
97
|
if (args.length !== 2) return { kind: "usage", message: USAGE };
|
|
97
|
-
const p = args[1]
|
|
98
|
-
if (!
|
|
98
|
+
const p = normalizeLifecyclePath(args[1]!);
|
|
99
|
+
if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid path.\n\n${USAGE}` };
|
|
99
100
|
return { kind: "create", target: { kind: "existing_path", path: p } };
|
|
100
101
|
}
|
|
101
102
|
if (kind === "dir") {
|
|
102
103
|
if (args.length !== 2) return { kind: "usage", message: USAGE };
|
|
103
|
-
const p = args[1]
|
|
104
|
-
if (!
|
|
104
|
+
const p = normalizeLifecyclePath(args[1]!);
|
|
105
|
+
if (!p) return { kind: "reject", reason: "invalid_target", message: `Invalid dir.\n\n${USAGE}` };
|
|
105
106
|
return { kind: "create", target: { kind: "plain_dir", path: p } };
|
|
106
107
|
}
|
|
107
108
|
if (kind === "worktree") {
|
|
108
109
|
if (args.length !== 3) return { kind: "usage", message: USAGE };
|
|
109
|
-
const repo = args[1]
|
|
110
|
+
const repo = normalizeLifecyclePath(args[1]!);
|
|
110
111
|
const branch = args[2]!;
|
|
111
|
-
if (!
|
|
112
|
-
return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
|
|
112
|
+
if (!repo) return { kind: "reject", reason: "invalid_target", message: `Invalid repo path.\n\n${USAGE}` };
|
|
113
113
|
if (!isSafeBranch(branch)) {
|
|
114
114
|
return { kind: "reject", reason: "invalid_target", message: `Invalid branch name.\n\n${USAGE}` };
|
|
115
115
|
}
|
|
@@ -137,10 +137,10 @@ export function validateLifecycleTarget(
|
|
|
137
137
|
if (verb === "session_create") {
|
|
138
138
|
const t = target as SessionCreateTarget;
|
|
139
139
|
if (t.kind === "existing_path" || t.kind === "plain_dir") {
|
|
140
|
-
return
|
|
140
|
+
return normalizeLifecyclePath(t.path) ? { ok: true } : bad("invalid path");
|
|
141
141
|
}
|
|
142
142
|
if (t.kind === "worktree") {
|
|
143
|
-
if (!
|
|
143
|
+
if (!normalizeLifecyclePath(t.repo)) return bad("invalid repo path");
|
|
144
144
|
return isSafeBranch(t.branch) ? { ok: true } : bad("invalid branch");
|
|
145
145
|
}
|
|
146
146
|
return bad("unknown create target");
|
|
@@ -156,6 +156,16 @@ export function validateLifecycleTarget(
|
|
|
156
156
|
// --- Safety primitives (defensive; the full-trust paired chat is accepted, but
|
|
157
157
|
// we still reject obviously malformed/injection-shaped inputs early). ---
|
|
158
158
|
|
|
159
|
+
export function normalizeLifecyclePath(value: string): string | undefined {
|
|
160
|
+
if (!isSafePath(value)) return undefined;
|
|
161
|
+
if (value === "~") return os.homedir() || undefined;
|
|
162
|
+
if (value.startsWith("~/")) {
|
|
163
|
+
const home = os.homedir();
|
|
164
|
+
return home ? `${home}${value.slice(1)}` : undefined;
|
|
165
|
+
}
|
|
166
|
+
if (value.startsWith("~")) return undefined;
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
159
169
|
function isSafeIdentifier(value: string): boolean {
|
|
160
170
|
return /^[A-Za-z0-9._-]{1,128}$/.test(value);
|
|
161
171
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
statusGjcTmuxSession,
|
|
23
23
|
} from "../gjc-runtime/tmux-sessions";
|
|
24
24
|
import type { ResumeCandidate, SessionCreateFrame, SessionLifecycleRequest, SessionLifecycleResponse } from "./index";
|
|
25
|
+
import { normalizeLifecyclePath } from "./lifecycle-commands";
|
|
25
26
|
import {
|
|
26
27
|
type AuditEvent,
|
|
27
28
|
type CreateEffectResult,
|
|
@@ -132,12 +133,16 @@ export function buildCreateArgv(
|
|
|
132
133
|
_ids: { intendedSessionId: string; startupPromptRef?: string },
|
|
133
134
|
): { cwd: string; args: string[] } {
|
|
134
135
|
if (frame.target.kind === "worktree") {
|
|
136
|
+
const cwd = normalizeLifecyclePath(frame.target.repo);
|
|
137
|
+
if (!cwd) throw new Error("invalid_lifecycle_repo_path");
|
|
135
138
|
// Use the `--worktree=<branch>` form so the branch is a single argv token:
|
|
136
139
|
// a flag-shaped branch (e.g. `-x`) can never be mis-parsed as a separate
|
|
137
140
|
// launcher flag / detached-mode trigger.
|
|
138
|
-
return { cwd
|
|
141
|
+
return { cwd, args: [`--worktree=${frame.target.branch}`] };
|
|
139
142
|
}
|
|
140
|
-
|
|
143
|
+
const cwd = normalizeLifecyclePath(frame.target.path);
|
|
144
|
+
if (!cwd) throw new Error("invalid_lifecycle_path");
|
|
145
|
+
return { cwd, args: [] };
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
/** Real daemon-safe tmux launcher: detached `tmux new-session -d` + GJC tags. */
|
|
@@ -1324,6 +1324,8 @@ const RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS = new Set([
|
|
|
1324
1324
|
"toolName",
|
|
1325
1325
|
"targetId",
|
|
1326
1326
|
"firstKeptEntryId",
|
|
1327
|
+
"encrypted_content",
|
|
1328
|
+
"reasoning_encrypted_content",
|
|
1327
1329
|
]);
|
|
1328
1330
|
|
|
1329
1331
|
function shouldExternalizeResidentString(key: string | undefined): boolean {
|
|
@@ -1515,6 +1517,40 @@ function cloneSessionEntry(entry: SessionEntry): SessionEntry {
|
|
|
1515
1517
|
return { ...entry, message: cloneAgentMessage(entry.message) } as SessionEntry;
|
|
1516
1518
|
}
|
|
1517
1519
|
|
|
1520
|
+
function materializeProviderVisibleEntrySync(entry: SessionEntry, stores: ResidentBlobStores): SessionEntry {
|
|
1521
|
+
if (entry.type === "compaction") {
|
|
1522
|
+
const cache = new Map<string, string>();
|
|
1523
|
+
const summary = materializeResidentValueSync(entry.summary, stores, "summary", cache);
|
|
1524
|
+
const shortSummary = materializeResidentValueSync(entry.shortSummary, stores, "shortSummary", cache);
|
|
1525
|
+
const remote = entry.preserveData?.openaiRemoteCompaction;
|
|
1526
|
+
const remoteRecord = isRecord(remote) ? remote : undefined;
|
|
1527
|
+
const replacementHistory = remoteRecord
|
|
1528
|
+
? materializeResidentValueSync(remoteRecord.replacementHistory, stores, "replacementHistory", cache)
|
|
1529
|
+
: undefined;
|
|
1530
|
+
const preserveData =
|
|
1531
|
+
remoteRecord && replacementHistory !== undefined && replacementHistory !== remoteRecord.replacementHistory
|
|
1532
|
+
? {
|
|
1533
|
+
...entry.preserveData,
|
|
1534
|
+
openaiRemoteCompaction: {
|
|
1535
|
+
...remoteRecord,
|
|
1536
|
+
replacementHistory,
|
|
1537
|
+
},
|
|
1538
|
+
}
|
|
1539
|
+
: entry.preserveData;
|
|
1540
|
+
return {
|
|
1541
|
+
...entry,
|
|
1542
|
+
summary: typeof summary === "string" ? summary : entry.summary,
|
|
1543
|
+
shortSummary: typeof shortSummary === "string" ? shortSummary : entry.shortSummary,
|
|
1544
|
+
preserveData,
|
|
1545
|
+
};
|
|
1546
|
+
}
|
|
1547
|
+
if (entry.type === "branch_summary") {
|
|
1548
|
+
const summary = materializeResidentValueSync(entry.summary, stores, "summary", new Map<string, string>());
|
|
1549
|
+
return typeof summary === "string" ? { ...entry, summary } : { ...entry };
|
|
1550
|
+
}
|
|
1551
|
+
return cloneSessionEntry(entry);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1518
1554
|
const COLD_SPILL_NOTICE = "[Compacted history content evicted to durable cold storage]";
|
|
1519
1555
|
const COLD_SPILL_ARGUMENTS_SENTINEL_KEY = "__gjcColdSpillArguments";
|
|
1520
1556
|
const COLD_SPILL_MIN_CHARS = 1024;
|
|
@@ -1533,19 +1569,6 @@ type ColdSpillResidentPromotion = {
|
|
|
1533
1569
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1534
1570
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1535
1571
|
}
|
|
1536
|
-
|
|
1537
|
-
function isColdSpillRef(value: unknown): value is ColdSpillRef {
|
|
1538
|
-
return (
|
|
1539
|
-
isRecord(value) &&
|
|
1540
|
-
value.kind === "cold_spill" &&
|
|
1541
|
-
typeof value.ref === "string" &&
|
|
1542
|
-
(value.encoding === "utf8" || value.encoding === "json") &&
|
|
1543
|
-
typeof value.originalChars === "number" &&
|
|
1544
|
-
typeof value.sha256 === "string" &&
|
|
1545
|
-
typeof value.bytes === "number"
|
|
1546
|
-
);
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
1572
|
function isColdSpillArgumentsSentinel(value: unknown): value is Record<string, unknown> {
|
|
1550
1573
|
return isRecord(value) && value[COLD_SPILL_ARGUMENTS_SENTINEL_KEY] === true;
|
|
1551
1574
|
}
|
|
@@ -4008,8 +4031,7 @@ export class SessionManager {
|
|
|
4008
4031
|
const firstKept = this.#byId.get(firstKeptEntryId);
|
|
4009
4032
|
const compaction = this.#byId.get(compactionEntryId);
|
|
4010
4033
|
if (!firstKept) throw new Error(`Entry ${firstKeptEntryId} not found`);
|
|
4011
|
-
if (
|
|
4012
|
-
throw new Error(`Compaction entry ${compactionEntryId} not found`);
|
|
4034
|
+
if (compaction?.type !== "compaction") throw new Error(`Compaction entry ${compactionEntryId} not found`);
|
|
4013
4035
|
const ids: string[] = [];
|
|
4014
4036
|
const visited = new Set<string>();
|
|
4015
4037
|
let current: SessionEntry | undefined = compaction;
|
|
@@ -4351,7 +4373,8 @@ export class SessionManager {
|
|
|
4351
4373
|
if (coldSpillPolicy === "covered" && (entry.type === "message" || entry.type === "custom_message")) {
|
|
4352
4374
|
return cloneSessionEntry(entry);
|
|
4353
4375
|
}
|
|
4354
|
-
if (entry.type !== "message" && entry.type !== "custom_message")
|
|
4376
|
+
if (entry.type !== "message" && entry.type !== "custom_message")
|
|
4377
|
+
return materializeProviderVisibleEntrySync(entry, this.#residentBlobStores());
|
|
4355
4378
|
const materialized = materializeResidentEntrySync(entry, this.#residentBlobStores(), new Map());
|
|
4356
4379
|
const rehydrated = rehydrateColdSpillEntry(
|
|
4357
4380
|
materialized,
|