jeopi-cli 16.6.9 → 16.6.12

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.
@@ -903,7 +903,19 @@ export class InputController {
903
903
  try {
904
904
  await this.ctx.withLocalSubmission(
905
905
  text,
906
- () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images }),
906
+ () => {
907
+ const submit = () => this.ctx.session.prompt(text, { streamingBehavior: "steer", images });
908
+ const bridge = this.ctx.liveDiscordBridge;
909
+ return bridge?.active
910
+ ? bridge.withLocalTurn(text, onPreparedUserMessage =>
911
+ this.ctx.session.prompt(text, {
912
+ streamingBehavior: "steer",
913
+ images,
914
+ onPreparedUserMessage,
915
+ }),
916
+ )
917
+ : submit();
918
+ },
907
919
  {
908
920
  imageCount: images?.length ?? 0,
909
921
  },
@@ -1229,9 +1241,19 @@ export class InputController {
1229
1241
  // Not streaming — just submit normally
1230
1242
  this.ctx.editor.clearDraft(text);
1231
1243
  try {
1232
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
1233
- imageCount: images?.length ?? 0,
1234
- });
1244
+ await this.ctx.withLocalSubmission(
1245
+ text,
1246
+ () => {
1247
+ const submit = () => this.ctx.session.prompt(text, { images });
1248
+ const bridge = this.ctx.liveDiscordBridge;
1249
+ return bridge?.active
1250
+ ? bridge.withLocalTurn(text, onPreparedUserMessage =>
1251
+ this.ctx.session.prompt(text, { images, onPreparedUserMessage }),
1252
+ )
1253
+ : submit();
1254
+ },
1255
+ { imageCount: images?.length ?? 0 },
1256
+ );
1235
1257
  } catch (error) {
1236
1258
  restoreOnError(error);
1237
1259
  }
@@ -80,6 +80,7 @@ import {
80
80
  MCP_CONNECTION_STATUS_EVENT_CHANNEL,
81
81
  type McpConnectionStatusEvent,
82
82
  } from "../mcp/startup-events";
83
+ import type { LiveDiscordBridge } from "../messenger/live-session";
83
84
  import {
84
85
  humanizePlanTitle,
85
86
  type PlanApprovalDetails,
@@ -491,6 +492,7 @@ export class InteractiveMode implements InteractiveModeContext {
491
492
  oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
492
493
  collabHost?: CollabHost;
493
494
  collabGuest?: CollabGuestLink;
495
+ liveDiscordBridge?: LiveDiscordBridge;
494
496
 
495
497
  #pendingSlashCommands: SlashCommand[] = [];
496
498
  #cleanupUnsubscribe?: () => void;
@@ -796,10 +798,27 @@ export class InteractiveMode implements InteractiveModeContext {
796
798
  // signal arriving mid-Ctrl+C no-ops instead of racing a second dispose.
797
799
  this.#signalTeardown = createSessionTeardown({
798
800
  getDraftText: () => this.editor.getText(),
801
+ beforeDispose: async () => {
802
+ const liveDiscordBridge = this.liveDiscordBridge;
803
+ this.liveDiscordBridge = undefined;
804
+ try {
805
+ await liveDiscordBridge?.close();
806
+ } catch (error) {
807
+ logger.warn("Failed to close live Discord bridge before teardown", { error: String(error) });
808
+ }
809
+ },
799
810
  beginDispose: () => this.session.beginDispose(),
800
811
  saveDraft: text => this.sessionManager.saveDraft(text),
801
- disposeSession: reason =>
802
- this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS, reason }),
812
+ disposeSession: async reason => {
813
+ const liveDiscordBridge = this.liveDiscordBridge;
814
+ this.liveDiscordBridge = undefined;
815
+ try {
816
+ await liveDiscordBridge?.close();
817
+ } catch (error) {
818
+ logger.warn("Failed to close live Discord bridge during teardown", { error: String(error) });
819
+ }
820
+ await this.session.dispose({ mnemopiConsolidateTimeoutMs: SHUTDOWN_CONSOLIDATE_BUDGET_MS, reason });
821
+ },
803
822
  });
804
823
  // Forward the postmortem reason (SIGTERM/SIGHUP/uncaughtException/…) so the
805
824
  // persisted `session_exit` diagnostic carries the real trigger. Postmortem
@@ -3349,14 +3368,19 @@ export class InteractiveMode implements InteractiveModeContext {
3349
3368
  this.#isShuttingDown = true;
3350
3369
 
3351
3370
  this.#btwController.dispose();
3352
- this.#omfgController.dispose();
3353
3371
  this.#focusController.dispose();
3354
-
3372
+ this.#omfgController.dispose();
3355
3373
  // Surface an explicit "Closing session…" line so the user sees a reason
3356
3374
  // for the pause while `session.dispose()` flushes memory consolidate and
3357
- // other cleanups (issue #3641). The await on the next line yields the
3358
- // event loop, giving requestRender() a tick to paint the status before
3359
- // dispose blocks.
3375
+ // other cleanups (issue #3641). The bridge is closed before disposal so
3376
+ // revoked Discord work cannot race session teardown.
3377
+ const liveDiscordBridge = this.liveDiscordBridge;
3378
+ this.liveDiscordBridge = undefined;
3379
+ try {
3380
+ await liveDiscordBridge?.close();
3381
+ } catch (error) {
3382
+ logger.warn("Failed to close live Discord bridge during shutdown", { error: String(error) });
3383
+ }
3360
3384
  this.showStatus("Closing session…");
3361
3385
 
3362
3386
  // Persist the draft and dispose the session through the shared teardown
@@ -66,6 +66,67 @@ describe("createSessionTeardown", () => {
66
66
  expect(order).toEqual(["snapshot", "beginDispose", "saveDraft:start", "saveDraft:done", "disposeSession"]);
67
67
  });
68
68
 
69
+ it("awaits beforeDispose before marking the session disposing", async () => {
70
+ const order: string[] = [];
71
+ const release = Promise.withResolvers<void>();
72
+
73
+ const teardown = createSessionTeardown({
74
+ getDraftText: () => "draft",
75
+ beforeDispose: async () => {
76
+ order.push("beforeDispose:start");
77
+ await release.promise;
78
+ order.push("beforeDispose:done");
79
+ },
80
+ beginDispose: () => {
81
+ order.push("beginDispose");
82
+ },
83
+ saveDraft: async () => {
84
+ order.push("saveDraft");
85
+ },
86
+ disposeSession: async () => {
87
+ order.push("disposeSession");
88
+ },
89
+ });
90
+
91
+ const running = teardown();
92
+ expect(order).toEqual(["beforeDispose:start"]);
93
+ release.resolve();
94
+ await running;
95
+
96
+ expect(order).toEqual([
97
+ "beforeDispose:start",
98
+ "beforeDispose:done",
99
+ "beginDispose",
100
+ "saveDraft",
101
+ "disposeSession",
102
+ ]);
103
+ });
104
+
105
+ it("continues teardown when beforeDispose rejects", async () => {
106
+ const order: string[] = [];
107
+
108
+ const teardown = createSessionTeardown({
109
+ getDraftText: () => "draft",
110
+ beforeDispose: async () => {
111
+ order.push("beforeDispose");
112
+ throw new Error("revocation failed");
113
+ },
114
+ beginDispose: () => {
115
+ order.push("beginDispose");
116
+ },
117
+ saveDraft: async () => {
118
+ order.push("saveDraft");
119
+ },
120
+ disposeSession: async () => {
121
+ order.push("disposeSession");
122
+ },
123
+ });
124
+
125
+ await teardown();
126
+
127
+ expect(order).toEqual(["beforeDispose", "beginDispose", "saveDraft", "disposeSession"]);
128
+ });
129
+
69
130
  it("still disposes when saveDraft rejects — never leaves session_shutdown unemitted", async () => {
70
131
  let disposed = false;
71
132
 
@@ -16,11 +16,16 @@ export interface SessionTeardownDeps {
16
16
  /** Snapshot the current editor text; called once, before disposal touches session state. */
17
17
  getDraftText: () => string;
18
18
  /**
19
- * Synchronously mark the session as disposing before any awaited teardown
20
- * work. This closes the async gap where deferred jobs could otherwise start
21
- * after a signal requested shutdown but before `disposeSession()` begins.
19
+ * Synchronously mark the session as disposing after asynchronous revocation
20
+ * completes and before any further awaited teardown work. This closes the async
21
+ * gap where deferred jobs could otherwise start after a signal requested shutdown.
22
22
  */
23
23
  beginDispose: () => void;
24
+ /**
25
+ * Complete asynchronous revocation before the session is marked disposed.
26
+ * Failures are logged and never skip the disposal chain.
27
+ */
28
+ beforeDispose?: () => Promise<void>;
24
29
  /**
25
30
  * Persist the snapshotted draft. Called even for an empty string so a
26
31
  * previously-persisted draft sidecar is cleared on a clean exit.
@@ -44,11 +49,11 @@ export interface SessionTeardownDeps {
44
49
  export type SessionTeardown = (reason?: postmortem.Reason) => Promise<void>;
45
50
 
46
51
  /**
47
- * Build a promise-memoized teardown function. The first call snapshots the
48
- * draft text, marks the session disposing synchronously, runs `saveDraft`
49
- * (draft-loss protection for `--resume`), then `disposeSession`; subsequent
50
- * calls await the same settled promise, so the keypress
51
- * `InteractiveMode.shutdown()` path and the postmortem signal callback cannot
52
+ * Build a promise-memoized teardown: snapshot the draft, complete asynchronous
53
+ * revocation, mark the session disposing synchronously, run `saveDraft` (draft-loss
54
+ * protection for `--resume`), then `disposeSession`; subsequent calls await the same
55
+ * settled promise so the keypress `InteractiveMode.shutdown()` path and the postmortem
56
+ * signal callback cannot
52
57
  * double-emit `session_shutdown`, double-dispose the session's async-job
53
58
  * manager, or race each other.
54
59
  *
@@ -67,6 +72,13 @@ export function createSessionTeardown(deps: SessionTeardownDeps): SessionTeardow
67
72
  let pending: Promise<void> | undefined;
68
73
  const run = async (reason?: postmortem.Reason): Promise<void> => {
69
74
  const draftText = deps.getDraftText();
75
+ if (deps.beforeDispose) {
76
+ try {
77
+ await deps.beforeDispose();
78
+ } catch (err) {
79
+ logger.warn("Failed pre-dispose cleanup during teardown", { error: String(err) });
80
+ }
81
+ }
70
82
  deps.beginDispose();
71
83
  try {
72
84
  await deps.saveDraft(draftText);
@@ -16,6 +16,7 @@ import type {
16
16
  import type { CompactOptions } from "../extensibility/extensions/types";
17
17
  import type { Skill } from "../extensibility/skills";
18
18
  import type { MCPManager } from "../mcp";
19
+ import type { LiveDiscordBridge } from "../messenger/live-session";
19
20
  import type { PlanApprovalDetails } from "../plan-mode/approved-plan";
20
21
  import type { AgentSession } from "../session/agent-session";
21
22
  import type { CompactMode } from "../session/compact-modes";
@@ -134,6 +135,7 @@ export interface InteractiveModeContext {
134
135
  collabGuest?: CollabGuestLink;
135
136
  eventController: EventController;
136
137
  eventBus?: EventBus;
138
+ liveDiscordBridge?: LiveDiscordBridge;
137
139
 
138
140
  // State
139
141
  isInitialized: boolean;
@@ -0,0 +1,4 @@
1
+ To deliver a requested file, write it inside {{directory}} and put [artifact:relative/path] on its own line in your final response. Only files in that directory can be uploaded; each must be at most 8 MiB. Never include credentials or secrets in output files.
2
+ {{#if inputs}}
3
+ Incoming files: {{inputs}}
4
+ {{/if}}
package/src/sdk.ts CHANGED
@@ -1921,11 +1921,18 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1921
1921
  const refreshedProviders = new Set<string>();
1922
1922
  for (const selector of discoverySelectors) {
1923
1923
  if (!selector) continue;
1924
- const parsed = parseModelString(selector, {
1925
- allowMaxAlias: true,
1926
- allowAutoAlias: true,
1927
- isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined,
1928
- });
1924
+ const parsed =
1925
+ parseModelString(selector, {
1926
+ allowMaxAlias: true,
1927
+ allowAutoAlias: true,
1928
+ isLiteralModelId: (provider, id) => modelRegistry.find(provider, id) !== undefined,
1929
+ }) ??
1930
+ (!selector.includes("/") &&
1931
+ !/[?*[\]]/.test(selector) &&
1932
+ /argon|gemini-(?:3\.8|4)/i.test(selector) &&
1933
+ !parseModelPattern(selector, modelRegistry.getAll(), modelMatchPreferences).model
1934
+ ? { provider: "google-antigravity", id: selector.split(":")[0] }
1935
+ : undefined);
1929
1936
  if (!parsed || /[?*[\]]/.test(parsed.id)) continue;
1930
1937
  const existing = modelRegistry.find(parsed.provider, parsed.id);
1931
1938
  if (existing && hasModelAuth(existing)) break;
@@ -97,6 +97,7 @@ import type {
97
97
  ToolChoice,
98
98
  Usage,
99
99
  UsageReport,
100
+ UserMessage,
100
101
  } from "jeopi-ai";
101
102
  import {
102
103
  calculateRateLimitBackoffMs,
@@ -944,6 +945,8 @@ export interface PromptOptions {
944
945
  userInitiated?: boolean;
945
946
  /** Explicit billing/initiator attribution for the prompt. Defaults to user prompts as `user` and synthetic prompts as `agent`. */
946
947
  attribution?: MessageAttribution;
948
+ /** Observe the exact user message prepared for this submission, including queued messages. */
949
+ onPreparedUserMessage?: (message: UserMessage) => void;
947
950
  /** Skip pre-send compaction checks for this prompt (internal use for maintenance flows). */
948
951
  skipCompactionCheck?: boolean;
949
952
  }
@@ -7539,9 +7542,9 @@ export class AgentSession {
7539
7542
  await this.sendCustomMessage(notice, { deliverAs: options.streamingBehavior });
7540
7543
  }
7541
7544
  if (options.streamingBehavior === "followUp") {
7542
- await this.#queueUserMessage(expandedText, options?.images, "followUp");
7545
+ await this.#queueUserMessage(expandedText, options?.images, "followUp", options?.onPreparedUserMessage);
7543
7546
  } else {
7544
- await this.#queueUserMessage(expandedText, options?.images, "steer");
7547
+ await this.#queueUserMessage(expandedText, options?.images, "steer", options?.onPreparedUserMessage);
7545
7548
  }
7546
7549
  return true;
7547
7550
  }
@@ -7568,6 +7571,7 @@ export class AgentSession {
7568
7571
  const message = options?.synthetic
7569
7572
  ? { role: "developer" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() }
7570
7573
  : { role: "user" as const, content: userContent, attribution: promptAttribution, timestamp: Date.now() };
7574
+ if (message.role === "user") options?.onPreparedUserMessage?.(message);
7571
7575
 
7572
7576
  const preludeMessages: AgentMessage[] = [];
7573
7577
  if (eagerTodoPrelude) {
@@ -8021,6 +8025,7 @@ export class AgentSession {
8021
8025
  text: string,
8022
8026
  images: ImageContent[] | undefined,
8023
8027
  mode: "steer" | "followUp",
8028
+ onPreparedUserMessage?: (message: UserMessage) => void,
8024
8029
  ): Promise<void> {
8025
8030
  // A queued user message (RPC/SDK/collab steer or follow-up, or a typed message
8026
8031
  // while streaming) is a deliberate resume; re-enable advisor auto-resume that
@@ -8038,21 +8043,20 @@ export class AgentSession {
8038
8043
  : undefined;
8039
8044
  if (mode === "followUp") {
8040
8045
  if (imageDescriptionNotice) this.agent.followUp(imageDescriptionNotice);
8041
- this.agent.followUp({
8042
- role: "user",
8043
- content,
8044
- attribution: "user",
8045
- timestamp: Date.now(),
8046
- });
8046
+ const message: UserMessage = { role: "user", content, attribution: "user", timestamp: Date.now() };
8047
+ onPreparedUserMessage?.(message);
8048
+ this.agent.followUp(message);
8047
8049
  } else {
8048
8050
  if (imageDescriptionNotice) this.agent.steer(imageDescriptionNotice);
8049
- this.agent.steer({
8051
+ const message: UserMessage = {
8050
8052
  role: "user",
8051
8053
  content,
8052
8054
  steering: true,
8053
8055
  attribution: "user",
8054
8056
  timestamp: Date.now(),
8055
- });
8057
+ };
8058
+ onPreparedUserMessage?.(message);
8059
+ this.agent.steer(message);
8056
8060
  }
8057
8061
  this.#scheduleIdleQueueDrain();
8058
8062
  }
@@ -24,6 +24,7 @@ import {
24
24
  MarketplaceManager,
25
25
  } from "../extensibility/plugins/marketplace";
26
26
  import { resolveMemoryBackend } from "../memory-backend";
27
+ import { loadAndCreateLiveDiscordBridge } from "../messenger/live-session";
27
28
  import { describeLoopLimitRuntime } from "../modes/loop-limit";
28
29
  import { theme } from "../modes/theme/theme";
29
30
  import type { InteractiveModeContext } from "../modes/types";
@@ -66,6 +67,13 @@ import type {
66
67
 
67
68
  export type { BuiltinSlashCommand, SubcommandDef } from "./types";
68
69
 
70
+ interface LiveDiscordStart {
71
+ cancelled: boolean;
72
+ promise: Promise<void>;
73
+ }
74
+
75
+ const liveDiscordStarts = new WeakMap<InteractiveModeContext, LiveDiscordStart>();
76
+
69
77
  /** TUI-specific runtime accepted by `executeBuiltinSlashCommand`. */
70
78
  export type BuiltinSlashCommandRuntime = TuiSlashCommandRuntime;
71
79
 
@@ -650,6 +658,125 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
650
658
  runtime.ctx.editor.setText("");
651
659
  },
652
660
  },
661
+ {
662
+ name: "discord",
663
+ description: "Opt in to this session from an authorized Discord channel",
664
+ inlineHint: "[live|relay|stop|status] [configPath|channelId|off] [--allow-full-control]",
665
+ subcommands: [
666
+ { name: "live", description: "Start the live Discord bridge with a private config file" },
667
+ { name: "relay", description: "Relay local responses to an allowed channel, or turn relay off" },
668
+ { name: "status", description: "Show live Discord bridge status" },
669
+ { name: "stop", description: "Stop the live Discord bridge" },
670
+ ],
671
+ allowArgs: true,
672
+ getTuiAutocompleteDescription: runtime =>
673
+ runtime.ctx.liveDiscordBridge?.active ? "Discord live bridge: active" : "Discord live bridge: off",
674
+ handleTui: async (command, runtime) => {
675
+ const ctx = runtime.ctx;
676
+ ctx.editor.setText("");
677
+ const { verb, rest } = parseSubcommand(command.args.trim());
678
+ if (verb === "status") {
679
+ if (!ctx.liveDiscordBridge?.active) {
680
+ ctx.showStatus("Discord live bridge is off");
681
+ return;
682
+ }
683
+ const approvalMode = String(ctx.session.settings.get("tools.approvalMode"));
684
+ const activeTools = ctx.session.getActiveToolNames();
685
+ ctx.showStatus(
686
+ `Discord live bridge is active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}. Local response relay: ${ctx.liveDiscordBridge.relayDestination ?? "off"}.`,
687
+ );
688
+ return;
689
+ }
690
+ if (verb === "relay") {
691
+ const bridge = ctx.liveDiscordBridge;
692
+ if (!bridge?.active) {
693
+ ctx.showError("Start the Discord live bridge before enabling relay");
694
+ return;
695
+ }
696
+ if (!rest || /\s/.test(rest)) {
697
+ ctx.showError("Usage: /discord relay <allowed-channel-id|off>");
698
+ return;
699
+ }
700
+ try {
701
+ bridge.setRelayDestination(rest === "off" ? undefined : rest);
702
+ ctx.showStatus(
703
+ rest === "off"
704
+ ? "Discord local response relay is off"
705
+ : `Discord local responses will be sent to channel ${rest}; /discord relay off revokes relay.`,
706
+ );
707
+ } catch (error) {
708
+ ctx.showError(`Failed to configure Discord relay: ${errorMessage(error)}`);
709
+ }
710
+ return;
711
+ }
712
+ if (verb === "stop") {
713
+ const bridge = ctx.liveDiscordBridge;
714
+ const start = liveDiscordStarts.get(ctx);
715
+ if (!bridge && start) {
716
+ start.cancelled = true;
717
+ ctx.showStatus("Discord live bridge startup cancellation requested");
718
+ return;
719
+ }
720
+ if (!bridge) {
721
+ ctx.showStatus("Discord live bridge is not active");
722
+ return;
723
+ }
724
+ ctx.liveDiscordBridge = undefined;
725
+ try {
726
+ await bridge.close();
727
+ ctx.showStatus("Discord live bridge stopped; access revoked");
728
+ } catch (error) {
729
+ ctx.showError(`Failed to stop Discord live bridge: ${errorMessage(error)}`);
730
+ }
731
+ return;
732
+ }
733
+ const acknowledgment = /^(.+?)\s+--allow-full-control$/.exec(rest);
734
+ if (verb !== "live" || !acknowledgment) {
735
+ ctx.showError("Usage: /discord live <private messenger.json> --allow-full-control");
736
+ return;
737
+ }
738
+ if (ctx.liveDiscordBridge?.active || liveDiscordStarts.has(ctx)) {
739
+ ctx.showError("Discord live bridge is already active or starting (/discord stop first)");
740
+ return;
741
+ }
742
+ let resolveStart!: () => void;
743
+ const startPromise = new Promise<void>(resolve => {
744
+ resolveStart = resolve;
745
+ });
746
+ const startState: LiveDiscordStart = { cancelled: false, promise: startPromise };
747
+ liveDiscordStarts.set(ctx, startState);
748
+ try {
749
+ const configPath = resolveToCwd(acknowledgment[1]!, ctx.sessionManager.getCwd());
750
+ const bridge = await loadAndCreateLiveDiscordBridge(ctx, configPath, {}, { acknowledgeFullControl: true });
751
+ if (startState.cancelled || ctx.shutdownRequested || ctx.isShuttingDown) {
752
+ await bridge.close().catch(() => {});
753
+ return;
754
+ }
755
+ if (ctx.liveDiscordBridge?.active) {
756
+ await bridge.close().catch(() => {});
757
+ ctx.showError("Discord live bridge is already active (/discord stop first)");
758
+ return;
759
+ }
760
+ ctx.liveDiscordBridge = bridge;
761
+ void bridge.run().catch(error => {
762
+ if (ctx.liveDiscordBridge !== bridge || ctx.isShuttingDown) return;
763
+ ctx.liveDiscordBridge = undefined;
764
+ ctx.showError(`Discord live bridge stopped: ${errorMessage(error)}`);
765
+ });
766
+ const approvalMode = String(ctx.session.settings.get("tools.approvalMode"));
767
+ const activeTools = ctx.session.getActiveToolNames();
768
+ ctx.showStatus(
769
+ `Discord live bridge active with full host control acknowledged. Approval mode: ${approvalMode}; active tools: ${activeTools.length > 0 ? activeTools.join(", ") : "none"}. /discord stop revokes access.`,
770
+ );
771
+ } catch (error) {
772
+ ctx.showError(`Failed to start Discord live bridge: ${errorMessage(error)}`);
773
+ } finally {
774
+ resolveStart();
775
+ if (liveDiscordStarts.get(ctx)?.promise === startPromise) liveDiscordStarts.delete(ctx);
776
+ }
777
+ },
778
+ },
779
+
653
780
  {
654
781
  name: "collab",
655
782
  description: "Share this session live via a relay",