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.
@@ -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
- // Round-13: verify the plan's hash matches the consensus hash to prevent silent edits
122
- if (ralplanState.consensus_hash) {
123
- const currentHash = createHash("sha256").update(planContent).digest("hex");
124
- if (currentHash !== ralplanState.consensus_hash) {
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
- };
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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `jeo daemon status|start|stop|reload` — manage the background Telegram
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.running) {
14
- console.log(`running (pid ${status.pid}, started ${new Date(status.startedAt!).toISOString()})`);
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, with static fallbacks so the list is
58
- // never empty. Pure function — mirrors launch.ts's exported `providerPickEntries`
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);
@@ -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, with static fallbacks so the list is never
339
- * empty.
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) so the user always sees at least one id.
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
- const cachedModels = await rehydrateLiveModels().catch(() => null);
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
- cachedModels?.providers.filter(entry => entry.provider === p).flatMap(entry => entry.models) ?? [];
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 ?? (cachedModels?.providers.flatMap(entry => entry.models) ?? []),
2445
+ liveModels: liveFromDiscovery ?? cachedCompletionModels.map(entry => entry.canonical),
2450
2446
  aliases: aliasNames,
2451
2447
  skillNames: resolvedSkillTokens,
2452
- modelsForProvider: p => liveModelsCache?.find(r => r.provider === p)?.models ?? cachedFor(p),
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
- void (async () => {
4195
- // Resolve any attached photo/document paths (already downloaded to
4196
- // local temp files by the daemon) into real ImageAttachments via the
4197
- // SAME path `attachImagePaths` uses for local drag-drop — one image
4198
- // ingestion mechanism, not a parallel one. Bare paths never contain
4199
- // whitespace here (daemon-generated temp names), so simple whitespace
4200
- // joining is a safe token boundary for the path-token scanner.
4201
- const withPaths = msg.imagePaths?.length ? `${msg.text} ${msg.imagePaths.join(" ")}`.trim() : msg.text;
4202
- const attached = await attachImagePaths(withPaths, pendingImages.length + 1);
4203
- const cleanedText = attached.images.length ? attached.text : msg.text;
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);
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;
@@ -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, maskToken } from "../agent/notify/telegram-api";
9
- import { daemonStatus } from "../agent/notify/daemon-control";
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
- function parseFlags(args: string[]): { token?: string; chatId?: string } {
12
- const out: { token?: string; chatId?: string } = {};
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
- 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];
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 runSetup(args: string[]): Promise<void> {
21
- const flags = parseFlags(args);
22
- let token = flags.token;
23
- let chatId = flags.chatId;
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
- 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;
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
- const rl = createInterface({ input: process.stdin, output: process.stdout });
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
- 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...`);
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 deadline = Date.now() + 120_000;
54
- while (Date.now() < deadline && !chatId) {
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
- 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
- }
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
- 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;
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 enabled = Boolean(config.notifications?.enabled);
95
- const botToken = config.notifications?.telegram?.botToken;
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 ? 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"}`);
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
- export async function runNotifyCommand(args: string[]): Promise<void> {
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
- 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]`);
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
  }
@@ -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
  }