jeo-code 0.7.35 → 0.7.38

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.
@@ -189,22 +189,23 @@ function stripAnchorPrefixes(block: string): string | null {
189
189
  if (!lines.every(l => l.trim() === "" || ANCHOR_PREFIX_RE.test(l))) return null;
190
190
  return lines.map(l => l.replace(ANCHOR_PREFIX_RE, "")).join("\n");
191
191
  }
192
- // ── File-freshness guard (plan/gjc-inheritance.md B7, gjc edit/file-read-cache 계승) ──
193
- // `read` records each file's stat fingerprint; `edit`/`write` verify it before
194
- // mutating. A file changed by someone else (concurrent agent, user, formatter)
195
- // between the read and the edit is REJECTED once — with the CURRENT content
196
- // re-presented so the model can retry immediately (recovery, not just a guard).
197
- const lastReadSnapshots = new Map<string, { mtimeMs: number; size: number }>();
192
+ // ── Blind-edit guard (plan/gjc-inheritance.md B7, gjc edit/file-read-cache 계승) ──
193
+ // `read` records that this session has SEEN a path; a line-range `edit` with
194
+ // no hash anchors on either bound refuses to run against a path never read
195
+ // this session (see the narrow guard below in editTool). NOTE: this module
196
+ // does NOT reject a write/edit just because the file changed on disk since
197
+ // the last read — the agent's write always wins (2026-07: guardrail removed
198
+ // per explicit user direction to stop blocking on external/concurrent edits).
199
+ const readThisSession = new Set<string>();
198
200
  const MAX_SNAPSHOT_ENTRIES = 64;
199
201
 
200
- function recordReadSnapshot(absPath: string, st: { mtimeMs: number; size: number } | null): void {
201
- if (!st) return;
202
- if (lastReadSnapshots.size >= MAX_SNAPSHOT_ENTRIES && !lastReadSnapshots.has(absPath)) {
203
- const oldest = lastReadSnapshots.keys().next().value;
204
- if (oldest !== undefined) lastReadSnapshots.delete(oldest);
202
+ function markRead(absPath: string): void {
203
+ if (readThisSession.size >= MAX_SNAPSHOT_ENTRIES && !readThisSession.has(absPath)) {
204
+ const oldest = readThisSession.values().next().value;
205
+ if (oldest !== undefined) readThisSession.delete(oldest);
205
206
  }
206
- lastReadSnapshots.delete(absPath); // re-insert to refresh LRU order
207
- lastReadSnapshots.set(absPath, { mtimeMs: st.mtimeMs, size: st.size });
207
+ readThisSession.delete(absPath); // re-insert to refresh LRU order
208
+ readThisSession.add(absPath);
208
209
  }
209
210
 
210
211
  /** Annotated excerpt of the file's CURRENT content for recovery errors (read-format `N|`). */
@@ -221,26 +222,6 @@ function excerptForRecovery(content: string, centerLine?: number): string {
221
222
  const note = lines.length > end - start + 1 ? `\n…(showing lines ${start}-${end} of ${lines.length})` : "";
222
223
  return body + note;
223
224
  }
224
-
225
- /** Returns a recovery error when `absPath` changed since the agent last read it; null when fresh.
226
- * Refreshes the snapshot to CURRENT so the immediate retry (model just saw fresh content) passes. */
227
- async function staleReadError(absPath: string, filePath: string, verb: string): Promise<ToolResult | null> {
228
- const snap = lastReadSnapshots.get(absPath);
229
- if (!snap) return null; // never read → no guard here (see the narrow blind-replace guard in editTool)
230
- const st = await fs.stat(absPath).catch(() => null);
231
- if (!st || !st.isFile()) return null; // deleted/replaced-by-dir: let the op surface its own error
232
- if (st.mtimeMs === snap.mtimeMs && st.size === snap.size) return null;
233
- const content = await fs.readFile(absPath, "utf-8").catch(() => null);
234
- recordReadSnapshot(absPath, st); // the model sees the fresh content below → retry passes
235
- const excerpt = content !== null ? `\nCurrent content:\n${excerptForRecovery(content)}` : "";
236
- return {
237
- success: false,
238
- output: "",
239
- error:
240
- `${verb} rejected: ${filePath} changed on disk since you last read it (another agent, the user, or a formatter touched it). ` +
241
- `Re-target your ${verb.toLowerCase()} against the CURRENT content below, then retry.${excerpt}`,
242
- };
243
- }
244
225
  export async function readTool(
245
226
  filePath: string,
246
227
  lineRange?: string | number,
@@ -261,7 +242,7 @@ export async function readTool(
261
242
  return lsTool(filePath, cwd);
262
243
  }
263
244
  const content = await fs.readFile(absPath, "utf-8");
264
- if (st?.isFile()) recordReadSnapshot(absPath, st); // arm the edit/write freshness guard
245
+ if (st?.isFile()) markRead(absPath); // arm the blind-edit read-first guard
265
246
 
266
247
  if (raw) {
267
248
  // Verbatim bytes, no "N|" line prefixes (gjc `:raw`), char-capped for context safety.
@@ -339,11 +320,9 @@ export async function writeTool(
339
320
  }
340
321
  await assertMutationAllowed(filePath, cwd);
341
322
  const absPath = path.resolve(cwd, filePath);
342
- const stale = await staleReadError(absPath, filePath, "Write");
343
- if (stale) return stale;
344
323
  await fs.mkdir(path.dirname(absPath), { recursive: true });
345
324
  await fs.writeFile(absPath, content, "utf-8");
346
- recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
325
+ markRead(absPath); // own change counts as having seen the file
347
326
  return { success: true, output: `Successfully wrote ${content.length} characters to ${filePath}` };
348
327
  } catch (err: any) {
349
328
  return { success: false, output: "", error: err.message };
@@ -392,8 +371,6 @@ export async function editTool(
392
371
  }
393
372
  await assertMutationAllowed(filePath, cwd);
394
373
  const absPath = path.resolve(cwd, filePath);
395
- const stale = await staleReadError(absPath, filePath, "Edit");
396
- if (stale) return stale;
397
374
  let content = await fs.readFile(absPath, "utf-8");
398
375
 
399
376
  // Line-anchored edit parser. Modes (payload follows the directive's newline):
@@ -517,7 +494,7 @@ export async function editTool(
517
494
  // (Insert/append directives above are unaffected: they don't destroy content.
518
495
  // Checked AFTER the bounds check so an out-of-range request still reports as
519
496
  // out-of-range rather than being masked by this guard.)
520
- if (!aStart && !aEnd && !lastReadSnapshots.has(absPath)) {
497
+ if (!aStart && !aEnd && !readThisSession.has(absPath)) {
521
498
  return {
522
499
  success: false,
523
500
  output: "",
@@ -605,7 +582,7 @@ export async function editTool(
605
582
  }
606
583
 
607
584
  await fs.writeFile(absPath, content, "utf-8");
608
- recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
585
+ markRead(absPath); // own change counts as having seen the file
609
586
  return { success: true, output: `Successfully updated ${filePath}` };
610
587
  } catch (err: any) {
611
588
  return { success: false, output: "", error: err.message };
@@ -1147,7 +1124,7 @@ export async function deleteTool(
1147
1124
  return { success: false, output: "", error: `${targetPath} is a directory — pass recursive:true to remove it and its contents.` };
1148
1125
  }
1149
1126
  await fs.rm(abs, { recursive, force: false });
1150
- lastReadSnapshots.delete(abs); // a future write to this path must not be flagged stale
1127
+ readThisSession.delete(abs); // a future edit against this path must read it again first
1151
1128
  return { success: true, output: `Deleted ${st.isDirectory() ? "directory" : "file"}: ${targetPath}` };
1152
1129
  } catch (err: any) {
1153
1130
  return { success: false, output: "", error: err.message };
package/src/cli/runner.ts CHANGED
@@ -101,6 +101,25 @@ export const COMMANDS: readonly CommandSpec[] = [
101
101
  return args => m.runMcpCommand(args);
102
102
  },
103
103
  },
104
+ {
105
+ name: "notify",
106
+ summary: "Configure/inspect remote subagent notifications over Telegram (gjc notify parity).",
107
+ usage: "notify [setup [--token <t> --chat-id <id>]|status]",
108
+ loader: async () => {
109
+ const m = await import("../commands/notify");
110
+ return args => m.runNotifyCommand(args);
111
+ },
112
+ },
113
+ {
114
+ name: "daemon",
115
+ summary: "Manage the background Telegram notification/subagent-control daemon.",
116
+ usage: "daemon [status|start|stop|reload]",
117
+ loader: async () => {
118
+ const m = await import("../commands/daemon");
119
+ return args => m.runDaemonCommand(args);
120
+ },
121
+ },
122
+
104
123
  {
105
124
  name: "skills",
106
125
  summary: "List bundled workflow skills, or sync them into ~/.jeo/skills (jeo skills <name> for details).",
@@ -155,6 +174,16 @@ export const COMMANDS: readonly CommandSpec[] = [
155
174
  return args => m.runMemoryMigrateCommand(args);
156
175
  },
157
176
  },
177
+ {
178
+ name: "notify-daemon-run",
179
+ summary: "(internal) Foreground worker for the Telegram notification daemon, spawned by 'jeo daemon start'.",
180
+ usage: "notify-daemon-run",
181
+ loader: async () => {
182
+ const m = await import("../agent/notify/telegram-daemon");
183
+ return async () => m.runNotifyDaemonForeground();
184
+ },
185
+ },
186
+
158
187
  {
159
188
  name: "state",
160
189
  summary: "Read or update workflow state receipts under .jeo/state (gjc-state parity).",
@@ -9,16 +9,20 @@ Concrete implementations of `jeo` subcommands (e.g., launch, setup, team, ultrag
9
9
  ## Key Files
10
10
  | File | Description |
11
11
  |------|-------------|
12
- | `approve.ts` | Brief description of purpose |
12
+ | `approve.ts` | `jeo approve <plan-path>` CLI + exported `approvePlan()` core (schema shape, known roles, persisted [OKAY] consensus, hash-vs-consensus match) — shared with the agent-facing `approve` tool (`src/agent/approve-tool.ts`) so the agent can flip a plan's `approved` flag itself; the identity restriction (human-only) was removed 2026-07 per explicit user direction, the content gate was not |
13
13
  | `auth.ts` | Brief description of purpose |
14
14
  | `chat.ts` | Brief description of purpose |
15
15
  | `deep-interview.ts` | Brief description of purpose |
16
+ | `daemon.ts` | `jeo daemon status\|start\|stop\|reload` — manages the background Telegram notification/subagent-control daemon (`src/agent/notify/daemon-control.ts`) |
16
17
  | `doctor.ts` | Brief description of purpose |
18
+
17
19
  | `evolve-core.ts` | Brief description of purpose |
18
20
  | `evolve.ts` | Brief description of purpose |
19
21
  | `export.ts` | Brief description of purpose |
20
22
  | `launch.ts` | The primary interactive/one-shot execution command |
21
23
  | `mcp.ts` | Brief description of purpose |
24
+ | `notify.ts` | `jeo notify setup\|status` — pairs a Telegram bot (BotFather token + `getMe` verification, private-chat pairing via `getUpdates`) and reports masked settings + daemon state |
25
+
22
26
  | `ooo-seed.ts` | Brief description of purpose |
23
27
  | `ralplan.ts` | Brief description of purpose |
24
28
  | `resume.ts` | Brief description of purpose |
@@ -9,14 +9,21 @@ import {
9
9
  import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
10
10
  import { getSubagentRole, subagentRoleIds } from "../agent/subagents";
11
11
 
12
- export async function runApproveCommand(args: string[] = []): Promise<void> {
13
- const cwd = process.cwd();
14
- const planPathInput = args[0];
12
+ export interface ApprovePlanResult {
13
+ ok: boolean;
14
+ message: string;
15
+ }
15
16
 
17
+ /** Core approval gate, shared by the `jeo approve` CLI command and the agent-facing
18
+ * `approve` tool (src/agent/approve-tool.ts). ALL of the plan-quality checks below
19
+ * (schema shape, known roles, persisted [OKAY] consensus verdict, hash-vs-consensus
20
+ * match) still apply no matter who calls this — approval is a content gate, not an
21
+ * identity gate. 2026-07: the identity gate (only a human running `jeo approve` in
22
+ * their own terminal could flip `approved: true`) was removed per explicit user
23
+ * direction — the agent can now call this itself via the `approve` tool. */
24
+ export async function approvePlan(planPathInput: string, cwd: string): Promise<ApprovePlanResult> {
16
25
  if (!planPathInput) {
17
- console.log("[ERROR] Plan path argument is required.");
18
- process.exitCode = 1;
19
- return;
26
+ return { ok: false, message: "[ERROR] Plan path argument is required." };
20
27
  }
21
28
 
22
29
  const resolvedInputPath = path.resolve(cwd, planPathInput);
@@ -25,23 +32,17 @@ export async function runApproveCommand(args: string[] = []): Promise<void> {
25
32
  try {
26
33
  await fs.access(resolvedInputPath);
27
34
  } catch {
28
- console.log(`[ERROR] Plan file not found: ${resolvedInputPath}`);
29
- process.exitCode = 1;
30
- return;
35
+ return { ok: false, message: `[ERROR] Plan file not found: ${resolvedInputPath}` };
31
36
  }
32
37
 
33
38
  // Read ralplan state
34
39
  const ralplanState = await readWorkflowState("ralplan", cwd);
35
40
  if (!ralplanState) {
36
- console.log(`[ERROR] No ralplan workflow state found. Please run 'jeo ralplan' first.`);
37
- process.exitCode = 1;
38
- return;
41
+ return { ok: false, message: `[ERROR] No ralplan workflow state found. Please run 'jeo ralplan' first.` };
39
42
  }
40
43
 
41
44
  if (!ralplanState.plan_path) {
42
- console.log(`[ERROR] No plan path associated with the current ralplan state.`);
43
- process.exitCode = 1;
44
- return;
45
+ return { ok: false, message: `[ERROR] No plan path associated with the current ralplan state.` };
45
46
  }
46
47
 
47
48
  // Compare canonical (symlink-resolved) paths so a relative arg, an absolute arg,
@@ -56,20 +57,19 @@ export async function runApproveCommand(args: string[] = []): Promise<void> {
56
57
  const resolvedStatePath = path.resolve(cwd, ralplanState.plan_path);
57
58
  const [canonInput, canonState] = await Promise.all([canonical(resolvedInputPath), canonical(resolvedStatePath)]);
58
59
  if (canonInput !== canonState) {
59
- console.log(
60
- `[ERROR] Provided plan path does not match the active plan in the ralplan state.\n` +
61
- ` provided: ${resolvedInputPath}\n` +
62
- ` active: ${resolvedStatePath}\n` +
63
- ` Run 'jeo approve "${resolvedStatePath}"' to approve the active plan.`
64
- );
65
- process.exitCode = 1;
66
- return;
60
+ return {
61
+ ok: false,
62
+ message:
63
+ `[ERROR] Provided plan path does not match the active plan in the ralplan state.\n` +
64
+ ` provided: ${resolvedInputPath}\n` +
65
+ ` active: ${resolvedStatePath}\n` +
66
+ ` Run 'jeo approve "${resolvedStatePath}"' to approve the active plan.`,
67
+ };
67
68
  }
68
69
 
69
70
  // Idempotency: check if already approved
70
71
  if (ralplanState.approved) {
71
- console.log(`[SUCCESS] Plan is already approved.`);
72
- return;
72
+ return { ok: true, message: `[SUCCESS] Plan is already approved.` };
73
73
  }
74
74
 
75
75
  // Round-10 #4 (architect ref 8-Round10Planning): approval is a GATE, not a
@@ -81,52 +81,53 @@ export async function runApproveCommand(args: string[] = []): Promise<void> {
81
81
  planContent = await fs.readFile(resolvedInputPath, "utf-8");
82
82
  const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(planContent)));
83
83
  if (!parsed.success) {
84
- console.log(
85
- `[ERROR] Refusing to approve: the plan is not in the shape 'jeo team' executes (top-level 'steps:' list of { name, role? }).\n` +
86
- ` ${parsed.error.issues[0]?.message ?? "schema mismatch"}\n` +
87
- ` Fix ${resolvedInputPath} or re-run 'jeo ralplan'.`,
88
- );
89
- process.exitCode = 1;
90
- return;
84
+ return {
85
+ ok: false,
86
+ message:
87
+ `[ERROR] Refusing to approve: the plan is not in the shape 'jeo team' executes (top-level 'steps:' list of { name, role? }).\n` +
88
+ ` ${parsed.error.issues[0]?.message ?? "schema mismatch"}\n` +
89
+ ` Fix ${resolvedInputPath} or re-run 'jeo ralplan'.`,
90
+ };
91
91
  }
92
92
  const cfg = await readGlobalConfig();
93
93
  const unknown = [...new Set(parsed.data.steps.map(s => s.role?.trim()).filter((r): r is string => !!r && !getSubagentRole(r, cfg)))];
94
94
  if (unknown.length > 0) {
95
- console.log(
96
- `[ERROR] Refusing to approve: plan references unknown subagent role(s): ${unknown.join(", ")}.\n` +
97
- ` Known roles: ${subagentRoleIds(cfg).join(", ")}. Fix ${resolvedInputPath} or re-run 'jeo ralplan'.`,
98
- );
99
- process.exitCode = 1;
100
- return;
95
+ return {
96
+ ok: false,
97
+ message:
98
+ `[ERROR] Refusing to approve: plan references unknown subagent role(s): ${unknown.join(", ")}.\n` +
99
+ ` Known roles: ${subagentRoleIds(cfg).join(", ")}. Fix ${resolvedInputPath} or re-run 'jeo ralplan'.`,
100
+ };
101
101
  }
102
102
  } catch (err: any) {
103
- console.log(`[ERROR] Refusing to approve: the plan file is not parseable YAML (${err.message}). Fix ${resolvedInputPath} or re-run 'jeo ralplan'.`);
104
- process.exitCode = 1;
105
- return;
103
+ return {
104
+ ok: false,
105
+ message: `[ERROR] Refusing to approve: the plan file is not parseable YAML (${err.message}). Fix ${resolvedInputPath} or re-run 'jeo ralplan'.`,
106
+ };
106
107
  }
107
108
 
108
109
  // Round-11: approval also requires the PERSISTED consensus verdict — a plan
109
110
  // that never passed (or failed) the critic gate cannot be approved. States
110
111
  // from older ralplan runs lack the field; re-running ralplan heals them.
111
112
  if (ralplanState.consensus !== "okay") {
112
- console.log(
113
- `[ERROR] Refusing to approve: the plan lacks an [OKAY] consensus verdict (recorded: ${ralplanState.consensus ?? "none"}).\n` +
114
- ` Re-run 'jeo ralplan' so the consensus critic can review the plan, then approve again.`,
115
- );
116
- process.exitCode = 1;
117
- return;
113
+ return {
114
+ ok: false,
115
+ message:
116
+ `[ERROR] Refusing to approve: the plan lacks an [OKAY] consensus verdict (recorded: ${ralplanState.consensus ?? "none"}).\n` +
117
+ ` Re-run 'jeo ralplan' so the consensus critic can review the plan, then approve again.`,
118
+ };
118
119
  }
119
120
 
120
121
  // Round-13: verify the plan's hash matches the consensus hash to prevent silent edits
121
122
  if (ralplanState.consensus_hash) {
122
123
  const currentHash = createHash("sha256").update(planContent).digest("hex");
123
124
  if (currentHash !== ralplanState.consensus_hash) {
124
- console.log(
125
- `[ERROR] Refusing to approve: the plan file has been modified since the consensus critic reviewed it.\n` +
126
- ` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve again.`
127
- );
128
- process.exitCode = 1;
129
- return;
125
+ return {
126
+ ok: false,
127
+ message:
128
+ `[ERROR] Refusing to approve: the plan file has been modified since the consensus critic reviewed it.\n` +
129
+ ` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve again.`,
130
+ };
130
131
  }
131
132
  }
132
133
 
@@ -134,5 +135,13 @@ export async function runApproveCommand(args: string[] = []): Promise<void> {
134
135
  ralplanState.approved = true;
135
136
  await writeWorkflowState("ralplan", ralplanState, cwd);
136
137
 
137
- console.log(`[SUCCESS] Plan approved successfully.`);
138
+ return { ok: true, message: `[SUCCESS] Plan approved successfully.` };
139
+ }
140
+
141
+ export async function runApproveCommand(args: string[] = []): Promise<void> {
142
+ const cwd = process.cwd();
143
+ const planPathInput = args[0];
144
+ const { ok, message } = await approvePlan(planPathInput, cwd);
145
+ console.log(message);
146
+ if (!ok) process.exitCode = 1;
138
147
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `jeo daemon status|start|stop|reload` — manage the background Telegram
3
+ * notification/subagent-control daemon (gjc `gjc daemon` parity, scoped to
4
+ * jeo's one daemon kind). See `src/agent/notify/daemon-control.ts`.
5
+ */
6
+ import { daemonStatus, startDaemon, stopDaemon, reloadDaemon } from "../agent/notify/daemon-control";
7
+
8
+ async function printStatus(): Promise<void> {
9
+ const status = await daemonStatus();
10
+ if (!status.configured) {
11
+ console.log("notifications not configured — run 'jeo notify setup' first.");
12
+ }
13
+ if (status.running) {
14
+ console.log(`running (pid ${status.pid}, started ${new Date(status.startedAt!).toISOString()})`);
15
+ } else if (status.stale) {
16
+ console.log("stale — a lock file exists but its pid is dead (previous daemon crashed without cleanup). 'jeo daemon start' will reclaim it.");
17
+ } else {
18
+ console.log("stopped");
19
+ }
20
+ }
21
+
22
+ export async function runDaemonCommand(args: string[]): Promise<void> {
23
+ const action = args[0] ?? "status";
24
+ if (action === "status" || action === "list") return printStatus();
25
+ if (action === "start") {
26
+ const res = await startDaemon();
27
+ console.log(res.message);
28
+ if (!res.ok) process.exitCode = 1;
29
+ return;
30
+ }
31
+ if (action === "stop") {
32
+ const res = await stopDaemon();
33
+ console.log(res.message);
34
+ if (!res.ok) process.exitCode = 1;
35
+ return;
36
+ }
37
+ if (action === "reload") {
38
+ const res = await reloadDaemon();
39
+ console.log(res.message);
40
+ if (!res.ok) process.exitCode = 1;
41
+ return;
42
+ }
43
+ console.log(`Unknown 'jeo daemon' action '${action}'. Usage: jeo daemon [status|start|stop|reload]`);
44
+ process.exitCode = 1;
45
+ }
@@ -8,11 +8,14 @@ import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "
8
8
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
9
9
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
10
10
  import { SubagentRegistry } from "../agent/subagent-registry";
11
+ import { stopSessionNotifyEndpoint } from "../agent/notify/session-endpoint";
12
+
11
13
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
12
14
  import { createJobTool, JOB_TOOL_PROTOCOL_LINE } from "../agent/job-tool";
13
15
  import { JobRegistry } from "../agent/job-registry";
14
16
  import { createIrcTool, IRC_TOOL_PROTOCOL_LINE } from "../agent/irc-tool";
15
17
  import { createGoalTool, GOAL_TOOL_PROTOCOL_LINE } from "../agent/goal-tool";
18
+ import { createApproveTool, APPROVE_TOOL_PROTOCOL_LINE } from "../agent/approve-tool";
16
19
  import { LaunchTui } from "../tui/app";
17
20
  import { runDeepInterviewEngine, type DeepInterviewEngineOptions } from "./deep-interview";
18
21
  import { runRalplanEngine, type RalplanEngineOptions } from "./ralplan";
@@ -525,7 +528,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
525
528
  // pi-style: load project context (JEO.md / AGENTS.md / .jeo/context.md / CLAUDE.md) into the prompt.
526
529
  const contextFiles = await loadProjectContext(cwd);
527
530
 
528
- const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
531
+ const KNOWN_TOOLS = new Set(["read", "write", "edit", "bash", "find", "search", "ls", "task", "todo", "subagent", "job", "irc", "goal", "approve", "ast_grep", "ast_edit", "computer", "lsp", "lsp_rename", "debug", "browser"]);
529
532
  let allowedTools = new Set(KNOWN_TOOLS);
530
533
 
531
534
  if (flags.noTools) {
@@ -599,6 +602,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
599
602
  (allowedTools.has("job") ? "\n\nBackground jobs: " + JOB_TOOL_PROTOCOL_LINE : "") +
600
603
  (allowedTools.has("irc") ? "\n\nPeer messaging: " + IRC_TOOL_PROTOCOL_LINE : "") +
601
604
  (allowedTools.has("goal") ? "\n\nGoal tracking: " + GOAL_TOOL_PROTOCOL_LINE : "") +
605
+ (allowedTools.has("approve") ? "\n\nPlan approval: " + APPROVE_TOOL_PROTOCOL_LINE : "") +
602
606
  (effectiveNoSkills ? "" :
603
607
  "\n\nJEO workflow routing:\n" +
604
608
  "- Answer the user's request DIRECTLY. Never reply with a catalog, list, or summary of skills unless the user explicitly asks what skills exist.\n" +
@@ -1108,6 +1112,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1108
1112
  job: createJobTool(jobRegistry),
1109
1113
  irc: createIrcTool(subagentRegistry),
1110
1114
  goal: createGoalTool(),
1115
+ approve: createApproveTool(),
1111
1116
  };
1112
1117
  const tools = filterToolMap(fullTools, Array.from(allowedTools));
1113
1118
  // Opik observability (opt-in via JEO_OPIK): one trace per turn, spans per
@@ -1166,7 +1171,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1166
1171
  } finally {
1167
1172
  harness.dispose();
1168
1173
  subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1174
+ await stopSessionNotifyEndpoint(subagentRegistry); // tear down the Telegram remote-control endpoint, if one was started
1169
1175
  jobRegistry.cancelAll(); // background jobs are turn-scoped too — no orphaned processes
1176
+
1170
1177
  // Steering typed but never drained (e.g. entered just after the final step)
1171
1178
  // must not be lost — fold it into the next prompt draft so it runs next.
1172
1179
  const leftover = steerInbox.splice(0, steerInbox.length).map(s => s.trim()).filter(Boolean);
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `jeo notify setup|status` — configure and inspect the remote subagent
3
+ * notification/control channel (gjc `gjc notify setup`/`gjc notify status`
4
+ * parity, scoped to jeo's Telegram + subagent surface; see `src/agent/notify/`).
5
+ */
6
+ import { createInterface } from "node:readline/promises";
7
+ import { readGlobalConfig, saveConfigPatch } from "../agent/state";
8
+ import { TelegramApi, maskToken } from "../agent/notify/telegram-api";
9
+ import { daemonStatus } from "../agent/notify/daemon-control";
10
+
11
+ function parseFlags(args: string[]): { token?: string; chatId?: string } {
12
+ const out: { token?: string; chatId?: string } = {};
13
+ for (let i = 0; i < args.length; i++) {
14
+ if (args[i] === "--token" && args[i + 1]) out.token = args[++i];
15
+ else if (args[i] === "--chat-id" && args[i + 1]) out.chatId = args[++i];
16
+ }
17
+ return out;
18
+ }
19
+
20
+ async function runSetup(args: string[]): Promise<void> {
21
+ const flags = parseFlags(args);
22
+ let token = flags.token;
23
+ let chatId = flags.chatId;
24
+
25
+ if (!token || !chatId) {
26
+ if (!process.stdin.isTTY) {
27
+ console.log(
28
+ "jeo notify setup needs an interactive terminal (TTY) unless both --token and --chat-id are given.\n" +
29
+ "Non-interactive: jeo notify setup --token <botToken> --chat-id <chatId>",
30
+ );
31
+ return;
32
+ }
33
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
34
+ try {
35
+ console.log("\n=== jeo notify setup (Telegram) ===");
36
+ console.log("Create a bot with @BotFather (https://core.telegram.org/bots/features#botfather), then paste its token below.\n");
37
+ if (!token) token = (await rl.question("Telegram BotFather token: ")).trim();
38
+ if (!token) {
39
+ console.log("No token given — aborting.");
40
+ return;
41
+ }
42
+ const api = new TelegramApi(token);
43
+ const me = await api.getMe();
44
+ if (!me.ok || !me.result) {
45
+ console.log(`Telegram getMe failed: ${me.description ?? "invalid token"}. Re-copy the token from BotFather and retry.`);
46
+ return;
47
+ }
48
+ console.log(`Bot verified: @${me.result.username}`);
49
+
50
+ if (!chatId) {
51
+ console.log(`\nMessage @${me.result.username} from your Telegram account now (any text). Waiting...`);
52
+ let offset: number | undefined;
53
+ const deadline = Date.now() + 120_000;
54
+ while (Date.now() < deadline && !chatId) {
55
+ const updates = await api.getUpdates(offset, 5);
56
+ if (updates.ok) {
57
+ for (const u of updates.result) {
58
+ offset = u.update_id + 1;
59
+ const chat = u.message?.chat;
60
+ if (chat && chat.type === "private") {
61
+ chatId = String(chat.id);
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ }
67
+ if (!chatId) {
68
+ console.log("Timed out waiting for a private message. Re-run 'jeo notify setup' and message the bot promptly.");
69
+ return;
70
+ }
71
+ console.log(`Paired private chat id ${chatId}.`);
72
+ }
73
+ } finally {
74
+ rl.close();
75
+ }
76
+ } else {
77
+ const api = new TelegramApi(token);
78
+ const me = await api.getMe();
79
+ if (!me.ok) {
80
+ console.log(`Telegram getMe failed: ${me.description ?? "invalid token"}.`);
81
+ return;
82
+ }
83
+ }
84
+
85
+ await saveConfigPatch(raw => ({
86
+ notifications: { ...raw.notifications, enabled: true, telegram: { botToken: token, chatId } },
87
+ }));
88
+ console.log(`\nNotifications enabled. botToken=${maskToken(token!)} chatId=${chatId}`);
89
+ console.log("Start the daemon with 'jeo daemon start', then run a detached subagent (task {detached:true}) to see it appear in Telegram.");
90
+ }
91
+
92
+ async function runStatus(): Promise<void> {
93
+ const config = await readGlobalConfig();
94
+ const enabled = Boolean(config.notifications?.enabled);
95
+ const botToken = config.notifications?.telegram?.botToken;
96
+ const chatId = config.notifications?.telegram?.chatId;
97
+ const status = await daemonStatus();
98
+ console.log(`enabled=${enabled}`);
99
+ console.log(`botToken=${botToken ? maskToken(botToken) : "(not set)"}`);
100
+ console.log(`chatId=${chatId ?? "(not set)"}`);
101
+ console.log(`daemon=${status.running ? `running (pid ${status.pid})` : status.stale ? "stale (crashed without cleanup)" : "stopped"}`);
102
+ }
103
+
104
+ export async function runNotifyCommand(args: string[]): Promise<void> {
105
+ const sub = args[0] ?? "status";
106
+ if (sub === "setup") return runSetup(args.slice(1));
107
+ if (sub === "status") return runStatus();
108
+ console.log(`Unknown 'jeo notify' subcommand '${sub}'. Usage: jeo notify [setup|status]`);
109
+ }