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.
- package/CHANGELOG.md +44 -0
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +2 -1
- package/src/agent/AGENTS.md +4 -1
- package/src/agent/approve-tool.ts +42 -0
- package/src/agent/config-schema.ts +17 -0
- package/src/agent/notify/AGENTS.md +47 -0
- package/src/agent/notify/daemon-control.ts +160 -0
- package/src/agent/notify/paths.ts +41 -0
- package/src/agent/notify/session-endpoint.ts +190 -0
- package/src/agent/notify/telegram-api.ts +83 -0
- package/src/agent/notify/telegram-daemon.ts +367 -0
- package/src/agent/state.ts +9 -0
- package/src/agent/task-tool.ts +6 -0
- package/src/agent/tools.ts +19 -42
- package/src/cli/runner.ts +29 -0
- package/src/commands/AGENTS.md +5 -1
- package/src/commands/approve.ts +63 -54
- package/src/commands/daemon.ts +45 -0
- package/src/commands/launch.ts +8 -1
- package/src/commands/notify.ts +109 -0
package/src/agent/tools.ts
CHANGED
|
@@ -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
|
-
// ──
|
|
193
|
-
// `read` records
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
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
|
|
201
|
-
if (!
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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())
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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).",
|
package/src/commands/AGENTS.md
CHANGED
|
@@ -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` |
|
|
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 |
|
package/src/commands/approve.ts
CHANGED
|
@@ -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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
+
}
|