jeo-code 0.11.1 → 0.11.3
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 +46 -1
- package/README.ja.md +60 -28
- package/README.ko.md +63 -30
- package/README.md +59 -27
- package/README.zh.md +62 -30
- package/package.json +1 -1
- package/src/agent/config-schema.ts +20 -7
- package/src/agent/debug-session.ts +7 -3
- package/src/agent/engine.ts +46 -102
- package/src/agent/loop-guards.ts +11 -13
- package/src/agent/notify/daemon-control.ts +109 -24
- package/src/agent/notify/discord-api.ts +148 -0
- package/src/agent/notify/discord-daemon.ts +443 -0
- package/src/agent/notify/paths.ts +1 -1
- package/src/agent/notify/session-discovery.ts +16 -0
- package/src/agent/notify/session-endpoint.ts +43 -25
- package/src/agent/notify/slack-api.ts +177 -0
- package/src/agent/notify/slack-daemon.ts +361 -0
- package/src/agent/notify/telegram-daemon.ts +215 -83
- package/src/agent/state.ts +13 -1
- package/src/ai/model-cache.ts +22 -15
- package/src/ai/model-catalog.ts +20 -11
- package/src/ai/model-discovery.ts +81 -54
- package/src/ai/model-picker.ts +4 -0
- package/src/autopilot.ts +14 -5
- package/src/cli/runner.ts +4 -4
- package/src/commands/approve.ts +21 -16
- package/src/commands/daemon.ts +5 -3
- package/src/commands/launch/agents-slash.ts +8 -3
- package/src/commands/launch.ts +45 -46
- package/src/commands/notify.ts +215 -83
- package/src/commands/ralplan.ts +2 -1
- package/src/commands/team.ts +17 -12
- package/src/commands/ultragoal.ts +2 -1
package/src/commands/approve.ts
CHANGED
|
@@ -67,11 +67,6 @@ export async function approvePlan(planPathInput: string, cwd: string): Promise<A
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Idempotency: check if already approved
|
|
71
|
-
if (ralplanState.approved) {
|
|
72
|
-
return { ok: true, message: `[SUCCESS] Plan is already approved.` };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
70
|
// Round-10 #4 (architect ref 8-Round10Planning): approval is a GATE, not a
|
|
76
71
|
// rubber stamp — validate the plan against the exact contract `jeo team`
|
|
77
72
|
// enforces, so a schema-invalid/unknown-role plan is refused HERE instead of
|
|
@@ -118,17 +113,27 @@ export async function approvePlan(planPathInput: string, cwd: string): Promise<A
|
|
|
118
113
|
};
|
|
119
114
|
}
|
|
120
115
|
|
|
121
|
-
//
|
|
122
|
-
if (ralplanState.consensus_hash) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
116
|
+
// Approval and idempotent re-approval both require the exact reviewed bytes.
|
|
117
|
+
if (!ralplanState.consensus_hash) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
message:
|
|
121
|
+
`[ERROR] Refusing to approve: the plan lacks a consensus review hash.\n` +
|
|
122
|
+
` Re-run 'jeo ralplan' so the consensus critic can review the plan, then approve again.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const currentHash = createHash("sha256").update(planContent).digest("hex");
|
|
126
|
+
if (currentHash !== ralplanState.consensus_hash) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
message:
|
|
130
|
+
`[ERROR] Refusing to approve: the plan file has been modified since the consensus critic reviewed it.\n` +
|
|
131
|
+
` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve again.`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (ralplanState.approved) {
|
|
136
|
+
return { ok: true, message: `[SUCCESS] Plan is already approved.` };
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
// Update ralplan-state.json to approved: true
|
package/src/commands/daemon.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `jeo daemon status|start|stop|reload` — manage the
|
|
2
|
+
* `jeo daemon status|start|stop|reload` — manage the shared Telegram/Discord/Slack
|
|
3
3
|
* notification/subagent-control daemon (gjc `gjc daemon` parity, scoped to
|
|
4
4
|
* jeo's one daemon kind). See `src/agent/notify/daemon-control.ts`.
|
|
5
5
|
*/
|
|
@@ -10,8 +10,10 @@ async function printStatus(): Promise<void> {
|
|
|
10
10
|
if (!status.configured) {
|
|
11
11
|
console.log("notifications not configured — run 'jeo notify setup' first.");
|
|
12
12
|
}
|
|
13
|
-
if (status.
|
|
14
|
-
console.log(`
|
|
13
|
+
if (status.pairing) {
|
|
14
|
+
console.log(`pairing owns polling (pid ${status.pid}); daemon not running`);
|
|
15
|
+
} else if (status.running) {
|
|
16
|
+
console.log(`process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}, started ${new Date(status.startedAt!).toISOString()}); platform connectivity not checked`);
|
|
15
17
|
// Say so when the PID-reuse guard could not actually run. On a host with no `/proc`
|
|
16
18
|
// and no usable `ps` (distroless images, seccomp-hardened sandboxes) "running" is an
|
|
17
19
|
// existence-only guess: the pid is alive, but it may belong to an unrelated process
|
|
@@ -54,12 +54,12 @@ function providerDefaultModel(p: ProviderName): string {
|
|
|
54
54
|
return openaiCompatDef(p)?.defaultModel ?? STATIC_PROVIDER_DEFAULT[p] ?? "";
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// Pick-list entries for ONE provider
|
|
58
|
-
//
|
|
57
|
+
// Pick-list entries for ONE provider. Antigravity requires live availability;
|
|
58
|
+
// other providers retain static fallbacks. Mirrors launch.ts's `providerPickEntries`
|
|
59
59
|
// (duplicated here rather than imported to avoid a launch.ts <-> launch/*.ts cycle).
|
|
60
60
|
function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
|
|
61
61
|
const fromLive = flattenModels(live.filter(r => r.provider === want));
|
|
62
|
-
if (fromLive.length) return fromLive;
|
|
62
|
+
if (fromLive.length || want === "antigravity") return fromLive;
|
|
63
63
|
const catalog = catalogByProvider(want);
|
|
64
64
|
if (catalog.length) {
|
|
65
65
|
return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
|
|
@@ -253,6 +253,11 @@ export async function runAgentsSlash(input: string, ctx: AgentsSlashCtx): Promis
|
|
|
253
253
|
const forProvider = providerPickEntries(live, want);
|
|
254
254
|
const liveForProvider = live.some(r => r.ok && r.provider === want && r.models.length > 0);
|
|
255
255
|
const explicit = tokens[3];
|
|
256
|
+
if (want === "antigravity" && !forProvider.length && (!explicit || explicit.startsWith("#"))) {
|
|
257
|
+
const reason = live.find(r => r.provider === want)?.error ?? "no account-available models returned";
|
|
258
|
+
console.log(`Cannot list ${want} models: ${reason}. Retry discovery or specify an explicit model id.`);
|
|
259
|
+
return result();
|
|
260
|
+
}
|
|
256
261
|
let chosenModel: string;
|
|
257
262
|
if (explicit && forProvider.length) {
|
|
258
263
|
const sel = resolveSelection(forProvider, explicit);
|
package/src/commands/launch.ts
CHANGED
|
@@ -55,7 +55,7 @@ import { callLlm, type Message } from "../agent/loop";
|
|
|
55
55
|
import { friendlyProviderError } from "../util/provider-error";
|
|
56
56
|
import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/state";
|
|
57
57
|
import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
|
|
58
|
-
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
58
|
+
import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, liveProviderCatalogModels, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
|
|
59
59
|
import { rehydrateLiveModels, writeModelCache } from "../ai/model-cache";
|
|
60
60
|
import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
|
|
61
61
|
import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
|
|
@@ -335,19 +335,22 @@ function providerDefaultModel(p: ProviderName): string {
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
/**
|
|
338
|
-
* Pick-list entries for ONE provider
|
|
339
|
-
*
|
|
338
|
+
* Pick-list entries for ONE provider. OpenAI and Antigravity availability comes
|
|
339
|
+
* only from live results; other providers retain their offline catalog fallbacks.
|
|
340
340
|
*
|
|
341
341
|
* Live discovery yields ids only for a logged-in, reachable provider, and
|
|
342
342
|
* `catalogOr` backfills the static catalog for OAuth sources and for API-key
|
|
343
343
|
* providers whose models-list endpoint is absent (HTTP 404, e.g. Tencent MaaS).
|
|
344
344
|
* A not-yet-configured provider still has an empty live list, so prefer live ids;
|
|
345
345
|
* else the provider's capability catalog; else its single known default model (all
|
|
346
|
-
* 24 OpenAI-compat providers carry one)
|
|
346
|
+
* 24 OpenAI-compat providers carry one). Antigravity never uses these fallbacks.
|
|
347
347
|
*/
|
|
348
348
|
export function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
|
|
349
349
|
const fromLive = flattenModels(live.filter(r => r.provider === want));
|
|
350
350
|
if (fromLive.length) return fromLive;
|
|
351
|
+
// An OpenAI or Antigravity empty/error response is authoritative for this picker.
|
|
352
|
+
// Offering the static capability catalog here would fabricate account availability.
|
|
353
|
+
if (want === "openai" || want === "antigravity") return [];
|
|
351
354
|
const catalog = catalogByProvider(want);
|
|
352
355
|
if (catalog.length) {
|
|
353
356
|
return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
|
|
@@ -1516,15 +1519,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1516
1519
|
|
|
1517
1520
|
const goalState = await readGoalState(cwd);
|
|
1518
1521
|
if (goalState && goalState.condition) {
|
|
1519
|
-
const reBlockCount = goalState.verdicts.filter(v => v.verdict === "NOT_MET" || v.verdict === "IMPOSSIBLE").length;
|
|
1520
|
-
const MAX_RE_BLOCKS = 2;
|
|
1521
|
-
|
|
1522
|
-
if (reBlockCount >= MAX_RE_BLOCKS) {
|
|
1523
|
-
if (tui) tui.events().onNotice?.(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
|
|
1524
|
-
else console.log(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
|
|
1525
|
-
return null;
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
1522
|
if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
|
|
1529
1523
|
const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
|
|
1530
1524
|
// Deterministic downgrade: a fresh MET from the transcript-only LLM
|
|
@@ -2427,7 +2421,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2427
2421
|
// the OAuth Codex gate from the first keystroke, instead of only after the network
|
|
2428
2422
|
// discovery below happens to land.
|
|
2429
2423
|
const aliasNames = Object.keys(await listAliases());
|
|
2430
|
-
|
|
2424
|
+
await rehydrateLiveModels().catch(() => null);
|
|
2425
|
+
const completionConfig = await readGlobalConfig();
|
|
2426
|
+
const cachedCompletionModels = liveProviderCatalogModels({ openaiBaseUrl: completionConfig.openaiBaseUrl ?? process.env.OPENAI_BASE_URL });
|
|
2431
2427
|
void getLiveModels()
|
|
2432
2428
|
.then(r => {
|
|
2433
2429
|
liveModelsCache ??= r;
|
|
@@ -2442,14 +2438,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2442
2438
|
// access to were un-completable for the first seconds of every session.
|
|
2443
2439
|
const liveFromDiscovery = liveModelsCache ? flattenModels(liveModelsCache).map(e => e.model) : null;
|
|
2444
2440
|
const cachedFor = (p: string): string[] =>
|
|
2445
|
-
|
|
2441
|
+
cachedCompletionModels.filter(entry => entry.provider === p).map(entry => entry.canonical);
|
|
2446
2442
|
return {
|
|
2447
2443
|
...base,
|
|
2448
2444
|
slashCommands: [...base.slashCommands, ...skillSlashDetails.map(d => d.command)],
|
|
2449
|
-
liveModels: liveFromDiscovery ??
|
|
2445
|
+
liveModels: liveFromDiscovery ?? cachedCompletionModels.map(entry => entry.canonical),
|
|
2450
2446
|
aliases: aliasNames,
|
|
2451
2447
|
skillNames: resolvedSkillTokens,
|
|
2452
|
-
modelsForProvider: p => liveModelsCache
|
|
2448
|
+
modelsForProvider: p => liveModelsCache
|
|
2449
|
+
? flattenModels(liveModelsCache.filter(r => r.provider === p)).map(entry => entry.model)
|
|
2450
|
+
: cachedFor(p),
|
|
2453
2451
|
mentionPaths,
|
|
2454
2452
|
};
|
|
2455
2453
|
};
|
|
@@ -4190,35 +4188,36 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
4190
4188
|
// `rl`/`promptInput`'s `pendingRemoteInject`, and `pendingStdinLines`, all
|
|
4191
4189
|
// established by now. See the Tier 2 contract's "Session-side routing".
|
|
4192
4190
|
if (sessionNotifyEndpoint) {
|
|
4193
|
-
sessionNotifyEndpoint.onUserMessage = (msg): void => {
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4209
|
-
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4191
|
+
sessionNotifyEndpoint.onUserMessage = async (msg): Promise<void | boolean> => {
|
|
4192
|
+
// Resolve any attached photo/document paths (already downloaded to
|
|
4193
|
+
// local temp files by the daemon) into real ImageAttachments via the
|
|
4194
|
+
// SAME path `attachImagePaths` uses for local drag-drop — one image
|
|
4195
|
+
// ingestion mechanism, not a parallel one. Bare paths never contain
|
|
4196
|
+
// whitespace here (daemon-generated temp names), so simple whitespace
|
|
4197
|
+
// joining is a safe token boundary for the path-token scanner.
|
|
4198
|
+
const withPaths = msg.imagePaths?.length ? `${msg.text} ${msg.imagePaths.join(" ")}`.trim() : msg.text;
|
|
4199
|
+
const attached = await attachImagePaths(withPaths, pendingImages.length + 1);
|
|
4200
|
+
const cleanedText = attached.images.length ? attached.text : msg.text;
|
|
4201
|
+
// Attachment normalization must not expose a remote slash command that
|
|
4202
|
+
// bypasses the local approval prompt or changes local execution policy.
|
|
4203
|
+
if (cleanedText.trimStart().startsWith("/")) return false;
|
|
4204
|
+
if (attached.images.length) pendingImages.push(...attached.images);
|
|
4205
|
+
|
|
4206
|
+
if (interactiveTurnActive && currentTurnSteer) {
|
|
4207
|
+
// Busy: steer the running turn exactly like a local mid-turn Enter.
|
|
4208
|
+
currentTurnSteer.push(cleanedText);
|
|
4209
|
+
currentTurnSteer.flushCard(cleanedText);
|
|
4210
|
+
return;
|
|
4211
|
+
}
|
|
4212
|
+
// Idle: never hijack text the user is actively typing locally — only
|
|
4213
|
+
// abort+inject when the readline buffer is confirmed empty (verified
|
|
4214
|
+
// empirically: aborting mid-partial-type corrupts the buffer).
|
|
4215
|
+
const rli = rl as unknown as { line?: string };
|
|
4216
|
+
if (rli.line === "" && pendingRemoteInject?.(cleanedText)) return;
|
|
4217
|
+
// No question pending, or the user is mid-type: queue for the NEXT
|
|
4218
|
+
// prompt cycle instead (same queue `pendingMidTurnCommands`/paste-merge
|
|
4219
|
+
// already feed — consumed at the top of the next `promptInput` call).
|
|
4220
|
+
pendingStdinLines.push(cleanedText);
|
|
4222
4221
|
};
|
|
4223
4222
|
sessionNotifyEndpoint.onConfigCommand = (cmd): void => {
|
|
4224
4223
|
if (cmd.verbosity !== undefined) notifyVerbosity = cmd.verbosity;
|
package/src/commands/notify.ts
CHANGED
|
@@ -1,109 +1,241 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Configure and inspect Telegram, Discord, and Slack notification channels. */
|
|
6
2
|
import { createInterface } from "node:readline/promises";
|
|
7
3
|
import { readGlobalConfig, saveConfigPatch } from "../agent/state";
|
|
8
|
-
import { TelegramApi
|
|
9
|
-
import {
|
|
4
|
+
import { TelegramApi } from "../agent/notify/telegram-api";
|
|
5
|
+
import { DiscordApi, DiscordApiError } from "../agent/notify/discord-api";
|
|
6
|
+
import { SlackApi, SlackApiError } from "../agent/notify/slack-api";
|
|
7
|
+
import { acquireDaemonLock, daemonStatus } from "../agent/notify/daemon-control";
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
type Provider = "telegram" | "discord" | "slack";
|
|
10
|
+
interface NotifyOptions {
|
|
11
|
+
fetchImpl?: typeof fetch;
|
|
12
|
+
prompt?: (question: string) => Promise<string>;
|
|
13
|
+
pairingChallenge?: () => string;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}
|
|
16
|
+
interface Flags {
|
|
17
|
+
provider: Provider;
|
|
18
|
+
token?: string;
|
|
19
|
+
tokenEnv?: string;
|
|
20
|
+
appTokenEnv?: string;
|
|
21
|
+
chatId?: string;
|
|
22
|
+
channelId?: string;
|
|
23
|
+
allowedUserIds?: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseFlags(args: string[]): Flags {
|
|
27
|
+
const out: Flags = { provider: "telegram" };
|
|
13
28
|
for (let i = 0; i < args.length; i++) {
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
const flag = args[i];
|
|
30
|
+
const value = args[++i];
|
|
31
|
+
if (!value || value.startsWith("--")) throw new Error("Missing value for notify option (use --token-env NAME for credentials)");
|
|
32
|
+
if (flag === "--provider" || flag === "--platform") {
|
|
33
|
+
if (value !== "telegram" && value !== "discord" && value !== "slack") throw new Error("Provider must be telegram, discord or slack");
|
|
34
|
+
out.provider = value;
|
|
35
|
+
} else if (flag === "--token") out.token = value;
|
|
36
|
+
else if (flag === "--token-env") out.tokenEnv = value;
|
|
37
|
+
else if (flag === "--app-token-env") out.appTokenEnv = value;
|
|
38
|
+
else if (flag === "--chat-id") out.chatId = value;
|
|
39
|
+
else if (flag === "--channel-id") out.channelId = value;
|
|
40
|
+
else if (flag === "--allowed-user-ids") {
|
|
41
|
+
out.allowedUserIds = [...new Set(value.split(",").map(id => id.trim()))];
|
|
42
|
+
} else throw new Error("Unknown notify option; use --provider, --token-env, --app-token-env, --chat-id, --channel-id or --allowed-user-ids");
|
|
43
|
+
}
|
|
44
|
+
if (out.chatId && !/^-?[1-9]\d*$/.test(out.chatId)) throw new Error("Telegram chat ID must be a nonzero decimal ID");
|
|
45
|
+
if (out.provider === "slack") {
|
|
46
|
+
if (out.channelId && !/^[CGD][A-Z0-9]+$/.test(out.channelId)) throw new Error("Slack channel ID must start with C, G or D and contain uppercase letters and digits");
|
|
47
|
+
if (out.allowedUserIds && !out.allowedUserIds.every(id => /^[UW][A-Z0-9]+$/.test(id))) throw new Error("Slack allowed user IDs must start with U or W and contain uppercase letters and digits");
|
|
48
|
+
} else {
|
|
49
|
+
if (out.channelId && !/^[1-9]\d{0,19}$/.test(out.channelId)) throw new Error("Discord channel ID must be a positive decimal ID of at most 20 digits");
|
|
50
|
+
if (out.allowedUserIds && !out.allowedUserIds.every(id => /^[1-9]\d{0,19}$/.test(id))) throw new Error("Allowed user IDs must be positive decimal IDs (up to 20 digits) separated by commas");
|
|
16
51
|
}
|
|
17
52
|
return out;
|
|
18
53
|
}
|
|
19
54
|
|
|
20
|
-
async function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
55
|
+
async function question(text: string, opts: NotifyOptions): Promise<string> {
|
|
56
|
+
if (opts.prompt) return (await opts.prompt(text)).trim();
|
|
57
|
+
if (!process.stdin.isTTY) throw new Error("Missing setup arguments; use --token-env and explicit destination/user IDs without a TTY");
|
|
58
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
59
|
+
try { return (await rl.question(text)).trim(); } finally { rl.close(); }
|
|
60
|
+
}
|
|
24
61
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
62
|
+
// Telegram puts credentials in request URLs. Never print underlying fetch errors.
|
|
63
|
+
function boundedFetch(opts: NotifyOptions): typeof fetch {
|
|
64
|
+
return ((input, init) => (opts.fetchImpl ?? fetch)(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(15_000) })) as typeof fetch;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function verifySlack(api: SlackApi, channelId: string): Promise<void> {
|
|
68
|
+
const me = await api.getMe();
|
|
69
|
+
const channel = await api.getChannel(channelId);
|
|
70
|
+
if (channel.id !== channelId || (channel.context_team_id && channel.context_team_id !== me.team_id)) throw new Error("Slack bot and destination workspace validation failed");
|
|
71
|
+
await api.openConnection();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function runSetup(flags: Flags, opts: NotifyOptions): Promise<void> {
|
|
75
|
+
const envName = flags.tokenEnv ?? `JEO_${flags.provider.toUpperCase()}_BOT_TOKEN`;
|
|
76
|
+
let token = flags.token ?? process.env[envName]?.trim();
|
|
77
|
+
if (!token) {
|
|
78
|
+
const name = await question(`Set the bot token in an environment variable, then enter its NAME (default ${envName}): `, opts);
|
|
79
|
+
token = process.env[name || envName]?.trim();
|
|
80
|
+
}
|
|
81
|
+
if (!token) throw new Error(`Bot token environment variable is empty; use --token-env NAME (preferred over --token)`);
|
|
82
|
+
const existing = (await readGlobalConfig()).notifications;
|
|
83
|
+
let allowedUserIds = flags.allowedUserIds;
|
|
84
|
+
if (flags.provider === "slack") {
|
|
85
|
+
const appEnvName = flags.appTokenEnv ?? "SLACK_APP_TOKEN";
|
|
86
|
+
let appToken = process.env[appEnvName]?.trim();
|
|
87
|
+
if (!appToken) {
|
|
88
|
+
const name = await question(`Set the Slack app token in an environment variable, then enter its NAME (default ${appEnvName}): `, opts);
|
|
89
|
+
appToken = process.env[name || appEnvName]?.trim();
|
|
32
90
|
}
|
|
33
|
-
|
|
91
|
+
if (!appToken) throw new Error("Slack app token environment variable is empty; use --app-token-env NAME");
|
|
92
|
+
const channelId = flags.channelId ?? existing?.slack?.channelId ?? await question("Slack channel ID: ", opts);
|
|
93
|
+
if (!/^[CGD][A-Z0-9]+$/.test(channelId)) throw new Error("Slack requires a valid channel ID");
|
|
94
|
+
const sameDestination = existing?.slack?.botToken === token && existing.slack.appToken === appToken && existing.slack.channelId === channelId;
|
|
95
|
+
if (!allowedUserIds?.length && (!sameDestination || !existing?.slack?.allowedUserIds.length)) allowedUserIds = (await question("Allowed Slack human user IDs (comma separated; required after token/destination changes): ", opts)).split(",").map(id => id.trim());
|
|
96
|
+
const api = new SlackApi(token, appToken, { fetchImpl: boundedFetch(opts) });
|
|
34
97
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
98
|
+
await verifySlack(api, channelId);
|
|
99
|
+
await saveConfigPatch(raw => {
|
|
100
|
+
const previous = raw.notifications?.slack;
|
|
101
|
+
const unchanged = previous?.botToken === token && previous.appToken === appToken && previous.channelId === channelId;
|
|
102
|
+
const owners = allowedUserIds ?? (unchanged ? previous.allowedUserIds : undefined);
|
|
103
|
+
if (!owners?.length || !owners.every(id => /^[UW][A-Z0-9]+$/.test(id))) throw new Error("Slack requires explicit allowed human user IDs; repeat --allowed-user-ids after token or destination changes");
|
|
104
|
+
return { notifications: { ...raw.notifications, enabled: true, slack: { botToken: token, appToken, channelId, allowedUserIds: owners } } };
|
|
105
|
+
});
|
|
106
|
+
} finally { api.stop(); }
|
|
107
|
+
console.log(`Slack bot and channel verified; channelId=${channelId}. Notifications enabled. Setup did not send a message.`);
|
|
108
|
+
console.log("Socket Mode URL verified; app-token ownership and socket connectivity are checked by the daemon handshake. Token rotation requires an explicit user allowlist.");
|
|
109
|
+
} else if (flags.provider === "discord") {
|
|
110
|
+
const channelId = flags.channelId ?? existing?.discord?.channelId ?? await question("Discord channel ID: ", opts);
|
|
111
|
+
const sameDestination = existing?.discord?.botToken === token && existing.discord.channelId === channelId;
|
|
112
|
+
if (!allowedUserIds?.length && (!sameDestination || !existing?.discord?.allowedUserIds?.length)) allowedUserIds = (await question("Allowed Discord user IDs (comma separated; required after token/destination changes): ", opts)).split(",").map(id => id.trim());
|
|
113
|
+
if (!/^[1-9]\d{0,19}$/.test(channelId)) throw new Error("Discord requires a positive channel ID (up to 20 digits)");
|
|
114
|
+
const api = new DiscordApi(token, { fetchImpl: boundedFetch(opts) });
|
|
115
|
+
const me = await api.getMe();
|
|
116
|
+
if (!me.bot) throw new Error("Discord identity is not a bot");
|
|
117
|
+
const channel = await api.getChannel(channelId);
|
|
118
|
+
if (channel.id !== channelId || ![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Discord destination must be a text channel, thread, or bot DM");
|
|
119
|
+
await saveConfigPatch(raw => {
|
|
120
|
+
const previous = raw.notifications?.discord;
|
|
121
|
+
const unchanged = previous?.botToken === token && previous.channelId === channelId;
|
|
122
|
+
const owners = allowedUserIds ?? (unchanged ? previous.allowedUserIds : undefined);
|
|
123
|
+
if (!owners?.length || !owners.every(id => /^[1-9]\d{0,19}$/.test(id))) throw new Error("Discord requires explicit allowed user IDs; repeat --allowed-user-ids after token or destination changes");
|
|
124
|
+
return { notifications: { ...raw.notifications, enabled: true, discord: { ...(unchanged ? previous : {}), botToken: token, channelId, allowedUserIds: owners } } };
|
|
125
|
+
});
|
|
126
|
+
console.log(`Discord bot verified; channelId=${channelId}. Notifications enabled.`);
|
|
127
|
+
console.log("Guild channels require MESSAGE CONTENT intent and View Channel/Send Messages permissions. Setup did not send a message.");
|
|
128
|
+
console.log("Token rotation or destination changes require an explicit user allowlist.");
|
|
129
|
+
} else {
|
|
130
|
+
const api = new TelegramApi(token, boundedFetch(opts));
|
|
131
|
+
const me = await api.getMe();
|
|
132
|
+
if (!me.ok || !me.result?.is_bot) throw new Error("Telegram getMe failed: invalid bot credentials or API unavailable");
|
|
133
|
+
const botId = String(me.result.id);
|
|
134
|
+
let chatId = flags.chatId;
|
|
135
|
+
let pairedUserId: string | undefined;
|
|
136
|
+
if (!chatId) {
|
|
137
|
+
if (!opts.prompt && !process.stdin.isTTY) throw new Error("Telegram auto-pairing requires a TTY; use explicit --chat-id otherwise");
|
|
138
|
+
const lock = await acquireDaemonLock("pairing");
|
|
139
|
+
if (!lock) throw new Error("A daemon or pairing process owns getUpdates; stop the daemon before auto-pairing, or use explicit --chat-id");
|
|
140
|
+
try {
|
|
141
|
+
const challenge = `/start jeo_${opts.pairingChallenge?.() ?? crypto.randomUUID()}`;
|
|
142
|
+
console.log(`Send exactly '${challenge}' to @${me.result.username} in a private chat within 120 seconds.`);
|
|
52
143
|
let offset: number | undefined;
|
|
53
|
-
const
|
|
54
|
-
|
|
144
|
+
const now = opts.now ?? Date.now;
|
|
145
|
+
const startedAt = now();
|
|
146
|
+
const deadline = startedAt + 120_000;
|
|
147
|
+
while (now() < deadline && !chatId) {
|
|
55
148
|
const updates = await api.getUpdates(offset, 5);
|
|
56
|
-
if (updates.ok)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
149
|
+
if (!updates.ok || !Array.isArray(updates.result)) throw new Error("Telegram pairing getUpdates failed (check bot polling ownership or credentials)");
|
|
150
|
+
for (const update of updates.result) {
|
|
151
|
+
if (!Number.isSafeInteger(update.update_id) || (offset !== undefined && update.update_id < offset)) continue;
|
|
152
|
+
offset = update.update_id + 1;
|
|
153
|
+
const msg = update.message;
|
|
154
|
+
if (msg?.chat.type === "private" && msg.date >= Math.floor(startedAt / 1000) && msg.text === challenge && msg.from && !msg.from.is_bot && String(msg.from.id) === String(msg.chat.id)) {
|
|
155
|
+
chatId = String(msg.chat.id);
|
|
156
|
+
pairedUserId = String(msg.from.id);
|
|
157
|
+
break;
|
|
64
158
|
}
|
|
65
159
|
}
|
|
66
160
|
}
|
|
67
|
-
if (!chatId)
|
|
68
|
-
|
|
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;
|
|
161
|
+
if (!chatId) throw new Error("Telegram pairing timed out; no matching private challenge received");
|
|
162
|
+
} finally { await lock.release(); }
|
|
82
163
|
}
|
|
164
|
+
const chat = await api.getChat(chatId);
|
|
165
|
+
if (!chat.ok || !chat.result?.type) throw new Error("Telegram chat lookup failed; check chat ID and bot access");
|
|
166
|
+
await saveConfigPatch(raw => {
|
|
167
|
+
const previous = raw.notifications?.telegram;
|
|
168
|
+
const sameDestination = previous?.chatId === chatId && (
|
|
169
|
+
previous.botToken === token || previous.botToken?.match(/^([1-9]\d*):/)?.[1] === botId
|
|
170
|
+
);
|
|
171
|
+
const owners = allowedUserIds ?? (sameDestination ? previous.allowedUserIds : undefined) ?? (pairedUserId ? [pairedUserId] : undefined);
|
|
172
|
+
if (chat.result!.type !== "private" && !owners?.length) throw new Error("Telegram groups require explicit --allowed-user-ids");
|
|
173
|
+
return { notifications: { ...raw.notifications, enabled: true, telegram: { ...(sameDestination ? previous : {}), botToken: token, chatId, ...(owners ? { allowedUserIds: owners } : {}) } } };
|
|
174
|
+
});
|
|
175
|
+
console.log(`Telegram bot verified; chatId=${chatId}. Notifications enabled. Setup did not send a message.`);
|
|
83
176
|
}
|
|
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.");
|
|
177
|
+
console.log("Run 'jeo daemon start' (or reload if already running) to apply. Use 'jeo notify health --provider " + flags.provider + "' for read-only API checks; 'jeo notify test --provider " + flags.provider + "' explicitly sends a test message.");
|
|
90
178
|
}
|
|
91
179
|
|
|
92
|
-
async function runStatus(): Promise<void> {
|
|
180
|
+
async function runStatus(provider: Provider): Promise<void> {
|
|
93
181
|
const config = await readGlobalConfig();
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
const chatId = config.notifications?.telegram?.chatId;
|
|
182
|
+
const n = config.notifications;
|
|
183
|
+
const channel = n?.[provider];
|
|
97
184
|
const status = await daemonStatus();
|
|
98
|
-
console.log(`enabled=${enabled}`);
|
|
99
|
-
console.log(`botToken=${botToken ?
|
|
100
|
-
console.log(`
|
|
101
|
-
console.log(`
|
|
185
|
+
console.log(`provider=${provider} enabled=${Boolean(n?.enabled)}`);
|
|
186
|
+
console.log(`botToken=${channel?.botToken ? "(set)" : "(not set)"}`);
|
|
187
|
+
if (provider === "slack") console.log(`appToken=${n?.slack?.appToken ? "(set)" : "(not set)"}`);
|
|
188
|
+
console.log(provider === "telegram" ? `chatId=${n?.telegram?.chatId ?? "(not set)"}` : `channelId=${n?.[provider]?.channelId ?? "(not set)"}`);
|
|
189
|
+
console.log(`allowedUserIds=${channel?.allowedUserIds?.join(",") ?? (provider === "telegram" ? "private chat owner only" : "(not set; inbound disabled)")}`);
|
|
190
|
+
console.log(`daemon=${status.pairing ? "pairing owns polling (daemon not running)" : status.running ? `process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}; transport connectivity not checked)` : status.stale ? "stale" : "stopped"}`);
|
|
191
|
+
console.log("Channel health not checked; use 'jeo notify health --provider " + provider + "'.");
|
|
102
192
|
}
|
|
103
193
|
|
|
104
|
-
|
|
194
|
+
async function runHealth(provider: Provider, send: boolean, opts: NotifyOptions): Promise<void> {
|
|
195
|
+
const n = (await readGlobalConfig()).notifications;
|
|
196
|
+
if (provider === "slack") {
|
|
197
|
+
const cfg = n?.slack;
|
|
198
|
+
if (!cfg?.botToken || !cfg.appToken || !cfg.channelId || !cfg.allowedUserIds?.length) throw new Error("Slack is incomplete: bot token, app token, channel ID and allowed user IDs are required");
|
|
199
|
+
const api = new SlackApi(cfg.botToken, cfg.appToken, { fetchImpl: boundedFetch(opts) });
|
|
200
|
+
try {
|
|
201
|
+
await verifySlack(api, cfg.channelId);
|
|
202
|
+
if (send) await api.sendMessage(cfg.channelId, "jeo notification test");
|
|
203
|
+
} finally { api.stop(); }
|
|
204
|
+
if (!send) console.log("Slack Socket Mode URL verified; app-token ownership, socket connectivity and send permissions are not tested until daemon handshake/test.");
|
|
205
|
+
} else if (provider === "discord") {
|
|
206
|
+
const cfg = n?.discord;
|
|
207
|
+
if (!cfg?.botToken || !cfg.channelId || !cfg.allowedUserIds?.length) throw new Error("Discord is incomplete: bot token, channel ID and allowed user IDs are required");
|
|
208
|
+
const api = new DiscordApi(cfg.botToken, { fetchImpl: boundedFetch(opts) });
|
|
209
|
+
const me = await api.getMe();
|
|
210
|
+
const channel = await api.getChannel(cfg.channelId);
|
|
211
|
+
if (!me.bot || channel.id !== cfg.channelId || ![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Discord identity/channel validation failed");
|
|
212
|
+
if (send) await api.sendMessage(cfg.channelId, "jeo notification test");
|
|
213
|
+
} else {
|
|
214
|
+
const cfg = n?.telegram;
|
|
215
|
+
if (!cfg?.botToken || !cfg.chatId) throw new Error("Telegram is incomplete: bot token and chat ID are required");
|
|
216
|
+
const api = new TelegramApi(cfg.botToken, boundedFetch(opts));
|
|
217
|
+
const me = await api.getMe();
|
|
218
|
+
const chat = await api.getChat(cfg.chatId);
|
|
219
|
+
if (!me.ok || !me.result?.is_bot || !chat.ok || !chat.result?.type) throw new Error("Telegram bot/chat validation failed (check credentials and access)");
|
|
220
|
+
if (chat.result.type !== "private" && !cfg.allowedUserIds?.length) throw new Error("Telegram groups require an explicit user allowlist");
|
|
221
|
+
if (send && !(await api.sendMessage(cfg.chatId, "jeo notification test", { messageThreadId: cfg.topicId })).ok) throw new Error("Telegram test send failed (check permissions/rate limit)");
|
|
222
|
+
}
|
|
223
|
+
console.log(`${provider}: ${send ? "test message sent" : "bot identity and destination accessible (read-only; Gateway/poll connectivity and send permissions not tested)"}. enabled=${Boolean(n?.enabled)}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function runNotifyCommand(args: string[], opts: NotifyOptions = {}): Promise<void> {
|
|
105
227
|
const sub = args[0] ?? "status";
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
228
|
+
try {
|
|
229
|
+
const flags = parseFlags(args.slice(1));
|
|
230
|
+
if (sub === "setup") return await runSetup(flags, opts);
|
|
231
|
+
if (sub === "status") return await runStatus(flags.provider);
|
|
232
|
+
if (sub === "health" || sub === "test") return await runHealth(flags.provider, sub === "test", opts);
|
|
233
|
+
throw new Error("Usage: jeo notify [setup|status|health|test] [--provider telegram|discord|slack]");
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// Only emit our known configuration/API errors, never fetch URLs or token-bearing bodies.
|
|
236
|
+
const message = error instanceof Error ? error.message : "request failed";
|
|
237
|
+
const safe = /^(Missing |Unknown notify|Provider |Allowed user|Telegram |Discord |Slack |Bot token|Missing setup|A daemon|Notification lock recovery|Usage:)/.test(message) && !message.includes("https:") && !message.includes("http:");
|
|
238
|
+
console.error(`Notification ${sub} failed: ${safe ? message : "API or storage request failed; check credentials, connectivity and permissions"}${(error instanceof DiscordApiError || error instanceof SlackApiError) && error.status ? ` (HTTP ${error.status})` : ""}`);
|
|
239
|
+
process.exitCode = 1;
|
|
240
|
+
}
|
|
109
241
|
}
|
package/src/commands/ralplan.ts
CHANGED
|
@@ -403,5 +403,6 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
403
403
|
}
|
|
404
404
|
|
|
405
405
|
export async function runRalplanCommand(): Promise<void> {
|
|
406
|
-
await runRalplanEngine();
|
|
406
|
+
const result = await runRalplanEngine();
|
|
407
|
+
if (!result.ok) process.exitCode = 1;
|
|
407
408
|
}
|