@gajae-code/coding-agent 0.11.6 → 0.11.7

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/types/capability/index.d.ts +10 -20
  3. package/dist/types/capability/types.d.ts +3 -0
  4. package/dist/types/commands/daemon.d.ts +2 -2
  5. package/dist/types/commands/sdk.d.ts +20 -2
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/settings-schema.d.ts +56 -6
  8. package/dist/types/eval/py/runner-artifact.d.ts +8 -0
  9. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
  10. package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
  11. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
  12. package/dist/types/main.d.ts +24 -1
  13. package/dist/types/modes/components/btw-panel.d.ts +1 -0
  14. package/dist/types/modes/components/model-selector.d.ts +2 -0
  15. package/dist/types/modes/components/tool-status-header.d.ts +1 -0
  16. package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
  17. package/dist/types/modes/interactive-mode.d.ts +2 -0
  18. package/dist/types/modes/types.d.ts +1 -0
  19. package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
  20. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  21. package/dist/types/sdk/bus/index.d.ts +1 -1
  22. package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
  23. package/dist/types/sdk/bus/notification-service.d.ts +34 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -3
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
  26. package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
  27. package/dist/types/sdk/client/discovery.d.ts +2 -0
  28. package/dist/types/sdk/client/liveness.d.ts +7 -0
  29. package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
  30. package/dist/types/sdk/transport/index.d.ts +12 -0
  31. package/dist/types/sdk/transport/relay.d.ts +43 -0
  32. package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
  33. package/dist/types/sdk/transport/socket.d.ts +7 -0
  34. package/dist/types/sdk/transport/stdio.d.ts +3 -0
  35. package/dist/types/session/agent-session.d.ts +28 -5
  36. package/dist/types/session/btw-contract.d.ts +16 -0
  37. package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
  38. package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
  39. package/dist/types/session/session-manager.d.ts +25 -4
  40. package/dist/types/setup/credential-import.d.ts +5 -1
  41. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  42. package/dist/types/ssh/utils.d.ts +1 -0
  43. package/dist/types/task/discovery.d.ts +3 -1
  44. package/dist/types/tools/fetch.d.ts +5 -8
  45. package/dist/types/tools/image-gen.d.ts +16 -0
  46. package/dist/types/tools/skill-discovery.d.ts +7 -0
  47. package/dist/types/tools/sqlite-reader.d.ts +7 -0
  48. package/dist/types/utils/shell-snapshot.d.ts +12 -0
  49. package/dist/types/web/insane/bridge.d.ts +1 -0
  50. package/dist/types/web/insane/url-guard.d.ts +17 -0
  51. package/package.json +8 -8
  52. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  53. package/src/capability/index.ts +50 -42
  54. package/src/capability/types.ts +3 -0
  55. package/src/cli/setup-cli.ts +10 -4
  56. package/src/cli.ts +34 -9
  57. package/src/commands/daemon.ts +22 -5
  58. package/src/commands/sdk.ts +42 -9
  59. package/src/config/keybindings.ts +1 -1
  60. package/src/config/model-profiles.ts +18 -0
  61. package/src/config/settings-schema.ts +57 -6
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
  63. package/src/discovery/ssh.ts +12 -1
  64. package/src/eval/py/kernel.ts +1 -21
  65. package/src/eval/py/runner-artifact.ts +84 -0
  66. package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
  67. package/src/gjc-runtime/ultragoal-guard.ts +88 -10
  68. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
  69. package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
  70. package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
  71. package/src/gjc-runtime/workflow-manifest.ts +23 -0
  72. package/src/internal-urls/docs-index.generated.ts +8 -7
  73. package/src/internal-urls/local-protocol.ts +32 -6
  74. package/src/main.ts +86 -27
  75. package/src/modes/action-registry.ts +1 -1
  76. package/src/modes/components/btw-panel.ts +82 -16
  77. package/src/modes/components/model-selector.ts +31 -3
  78. package/src/modes/components/tool-status-header.ts +6 -1
  79. package/src/modes/controllers/btw-controller.ts +119 -36
  80. package/src/modes/controllers/input-controller.ts +31 -0
  81. package/src/modes/controllers/selector-controller.ts +106 -12
  82. package/src/modes/interactive-mode.ts +16 -7
  83. package/src/modes/types.ts +1 -0
  84. package/src/prompts/system/btw-user.md +3 -8
  85. package/src/prompts/tools/skill-discovery.md +1 -0
  86. package/src/runtime-mcp/client.ts +41 -55
  87. package/src/runtime-mcp/content-limits.ts +71 -0
  88. package/src/runtime-mcp/json-rpc.ts +23 -9
  89. package/src/runtime-mcp/transports/http.ts +50 -16
  90. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  91. package/src/sdk/bus/index.ts +4 -5
  92. package/src/sdk/bus/lifecycle-commands.ts +16 -7
  93. package/src/sdk/bus/notification-service.ts +41 -18
  94. package/src/sdk/bus/telegram-daemon-contract.ts +8 -3
  95. package/src/sdk/bus/telegram-daemon-control.ts +2 -2
  96. package/src/sdk/bus/telegram-daemon.ts +1663 -375
  97. package/src/sdk/bus/topic-registry.ts +451 -12
  98. package/src/sdk/client/discovery.ts +19 -3
  99. package/src/sdk/client/liveness.ts +49 -0
  100. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  101. package/src/sdk/session.ts +29 -5
  102. package/src/sdk/transport/auth-preface.ts +82 -0
  103. package/src/sdk/transport/index.ts +18 -0
  104. package/src/sdk/transport/relay.ts +252 -0
  105. package/src/sdk/transport/serve-cli.ts +105 -0
  106. package/src/sdk/transport/socket.ts +197 -0
  107. package/src/sdk/transport/stdio.ts +17 -0
  108. package/src/session/agent-session.ts +240 -149
  109. package/src/session/blob-store.ts +19 -6
  110. package/src/session/btw-contract.ts +59 -0
  111. package/src/session/internal/managed-session-scope.ts +313 -57
  112. package/src/session/internal/managed-session-storage.ts +146 -35
  113. package/src/session/session-manager.ts +319 -85
  114. package/src/setup/credential-import.ts +28 -3
  115. package/src/setup/provider-onboarding.ts +30 -1
  116. package/src/setup/provider-presets.json +2 -1
  117. package/src/slash-commands/builtin-registry.ts +1 -1
  118. package/src/ssh/utils.ts +14 -0
  119. package/src/task/discovery.ts +8 -2
  120. package/src/tools/ask.ts +1 -1
  121. package/src/tools/fetch.ts +13 -131
  122. package/src/tools/image-gen.ts +301 -20
  123. package/src/tools/read.ts +2 -0
  124. package/src/tools/skill-discovery.ts +39 -3
  125. package/src/tools/sqlite-reader.ts +63 -5
  126. package/src/utils/pasted-image-path.ts +15 -6
  127. package/src/utils/shell-snapshot.ts +173 -63
  128. package/src/web/insane/bridge.ts +5 -1
  129. package/src/web/insane/url-guard.ts +138 -27
  130. package/src/web/scrapers/types.ts +19 -49
  131. package/src/web/scrapers/utils.ts +12 -26
@@ -0,0 +1,197 @@
1
+ import * as fsSync from "node:fs";
2
+ import * as fs from "node:fs/promises";
3
+ import * as net from "node:net";
4
+ import * as path from "node:path";
5
+ import { MAX_UNIX_SOCKET_PATH_BYTES } from "../../harness-control-plane/storage";
6
+ import { authenticatePreface } from "./auth-preface";
7
+ import type { ServeHandle, ServeOptions } from "./index";
8
+ import type { RelayOptions, RelayPair, TransportError } from "./relay";
9
+ import { startRelayPair } from "./relay";
10
+
11
+ function socketFailure(code: string): Error {
12
+ return new Error(code);
13
+ }
14
+
15
+ async function validateSocketPath(socketPath: string): Promise<void> {
16
+ if (process.platform === "win32") throw socketFailure("unsupported_platform");
17
+ if (Buffer.byteLength(socketPath) > MAX_UNIX_SOCKET_PATH_BYTES) throw socketFailure("socket_path_too_long");
18
+ try {
19
+ await fs.lstat(socketPath);
20
+ throw socketFailure("socket_path_in_use");
21
+ } catch (error) {
22
+ if (error instanceof Error && error.message === "socket_path_in_use") throw error;
23
+ const code = error as NodeJS.ErrnoException;
24
+ if (code.code !== "ENOENT") throw error;
25
+ }
26
+ let parent: Awaited<ReturnType<typeof fs.stat>>;
27
+ try {
28
+ parent = await fs.stat(path.dirname(socketPath));
29
+ } catch {
30
+ throw socketFailure("socket_dir_insecure");
31
+ }
32
+ if (!parent.isDirectory() || parent.uid !== process.getuid?.() || (parent.mode & 0o022) !== 0)
33
+ throw socketFailure("socket_dir_insecure");
34
+ }
35
+
36
+ function sendTransportError(socket: net.Socket, error: TransportError): void {
37
+ socket.write(`${JSON.stringify(error)}\n`);
38
+ }
39
+
40
+ /** Serves authenticated SDK JSONL clients over a private Unix-domain socket. */
41
+ export async function startSocketServe(
42
+ options: ServeOptions & { socketPath: string; webSocketFactory?: RelayOptions["webSocketFactory"] },
43
+ ): Promise<ServeHandle> {
44
+ await validateSocketPath(options.socketPath);
45
+ const pairs = new Set<RelayPair>();
46
+ const sockets = new Set<net.Socket>();
47
+ const connectionTasks = new Set<Promise<void>>();
48
+ const dialControllers = new Set<AbortController>();
49
+ let server: net.Server | undefined;
50
+ let ownedIdentity: { dev: number; ino: number } | undefined;
51
+ let closing: Promise<void> | undefined;
52
+ const done = Promise.withResolvers<void>();
53
+ void done.promise.catch(() => undefined);
54
+
55
+ const removeOwnedSocket = async (): Promise<void> => {
56
+ if (!ownedIdentity) return;
57
+ try {
58
+ const current = await fs.lstat(options.socketPath);
59
+ if (current.isSocket() && current.dev === ownedIdentity.dev && current.ino === ownedIdentity.ino)
60
+ await fs.unlink(options.socketPath);
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
63
+ }
64
+ };
65
+
66
+ const removeOwnedSocketSync = (): void => {
67
+ if (!ownedIdentity) return;
68
+ try {
69
+ const current = fsSync.lstatSync(options.socketPath);
70
+ if (current.isSocket() && current.dev === ownedIdentity.dev && current.ino === ownedIdentity.ino)
71
+ fsSync.unlinkSync(options.socketPath);
72
+ } catch (error) {
73
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
74
+ }
75
+ };
76
+ const cleanupOnExit = (): void => removeOwnedSocketSync();
77
+ process.once("exit", cleanupOnExit);
78
+ const close = async (fatalError?: Error): Promise<void> => {
79
+ if (closing) return await closing;
80
+ closing = (async () => {
81
+ const errors: Error[] = fatalError ? [fatalError] : [];
82
+ for (const controller of dialControllers) controller.abort();
83
+ for (const socket of sockets) socket.destroy();
84
+ const pairResults = await Promise.allSettled([...pairs].map(pair => pair.close()));
85
+ for (const result of pairResults) {
86
+ if (result.status === "rejected")
87
+ errors.push(result.reason instanceof Error ? result.reason : new Error(String(result.reason)));
88
+ }
89
+ const connectionResults = await Promise.allSettled([...connectionTasks]);
90
+ for (const result of connectionResults) {
91
+ if (result.status === "rejected")
92
+ errors.push(result.reason instanceof Error ? result.reason : new Error(String(result.reason)));
93
+ }
94
+ let displacedPath: string | undefined;
95
+ if (server?.listening && ownedIdentity) {
96
+ try {
97
+ const current = await fs.lstat(options.socketPath).catch(() => undefined);
98
+ if (current && (current.dev !== ownedIdentity.dev || current.ino !== ownedIdentity.ino)) {
99
+ displacedPath = `${options.socketPath}.${process.pid}.closing`;
100
+ await fs.rename(options.socketPath, displacedPath);
101
+ }
102
+ } catch (error) {
103
+ errors.push(error instanceof Error ? error : new Error(String(error)));
104
+ }
105
+ }
106
+ if (server?.listening) {
107
+ try {
108
+ await new Promise<void>((resolve, reject) =>
109
+ server?.close(error => (error ? reject(error) : resolve())),
110
+ );
111
+ } catch (error) {
112
+ errors.push(error instanceof Error ? error : new Error(String(error)));
113
+ }
114
+ }
115
+ if (displacedPath) {
116
+ try {
117
+ await fs.rename(displacedPath, options.socketPath);
118
+ } catch (error) {
119
+ errors.push(error instanceof Error ? error : new Error(String(error)));
120
+ }
121
+ }
122
+ try {
123
+ await removeOwnedSocket();
124
+ } catch (error) {
125
+ errors.push(error instanceof Error ? error : new Error(String(error)));
126
+ }
127
+ process.removeListener("exit", cleanupOnExit);
128
+ if (errors.length > 0) {
129
+ const error = new AggregateError(errors, "socket_serve_close_failed");
130
+ done.reject(error);
131
+ throw error;
132
+ }
133
+ done.resolve();
134
+ })();
135
+ return await closing;
136
+ };
137
+ const handleConnection = async (socket: net.Socket, signal: AbortSignal): Promise<void> => {
138
+ sockets.add(socket);
139
+ socket.once("close", () => sockets.delete(socket));
140
+ try {
141
+ const initialDownstreamBytes = await authenticatePreface(socket, options.token);
142
+ if (closing) {
143
+ socket.destroy();
144
+ return;
145
+ }
146
+ const pair = await startRelayPair({
147
+ ...options,
148
+ signal,
149
+ downstream: socket,
150
+ downstreamSink: socket,
151
+ initialDownstreamBytes,
152
+ onTransportError: error => sendTransportError(socket, error),
153
+ });
154
+ pairs.add(pair);
155
+ try {
156
+ await pair.done.catch(() => undefined);
157
+ } finally {
158
+ pairs.delete(pair);
159
+ socket.end();
160
+ }
161
+ } catch {
162
+ socket.destroy();
163
+ }
164
+ };
165
+ server = net.createServer(socket => {
166
+ if (closing) {
167
+ socket.destroy();
168
+ return;
169
+ }
170
+ const controller = new AbortController();
171
+ dialControllers.add(controller);
172
+ const task = handleConnection(socket, controller.signal).finally(() => {
173
+ dialControllers.delete(controller);
174
+ connectionTasks.delete(task);
175
+ });
176
+ connectionTasks.add(task);
177
+ void task;
178
+ });
179
+ server.on("error", error => void close(error).catch(() => undefined));
180
+ await new Promise<void>((resolve, reject) => {
181
+ server?.once("error", reject);
182
+ server?.listen(options.socketPath, () => resolve());
183
+ });
184
+ const bound = await fs.lstat(options.socketPath);
185
+ if (!bound.isSocket()) {
186
+ await close();
187
+ throw socketFailure("socket_bind_failed");
188
+ }
189
+ ownedIdentity = { dev: bound.dev, ino: bound.ino };
190
+ try {
191
+ await fs.chmod(options.socketPath, 0o600);
192
+ } catch (error) {
193
+ await close();
194
+ throw error;
195
+ }
196
+ return { close, done: done.promise };
197
+ }
@@ -0,0 +1,17 @@
1
+ import type { ServeHandle, ServeOptions } from "./index";
2
+ import { startRelayPair } from "./relay";
3
+
4
+ function writeDiagnostic(value: unknown): void {
5
+ process.stderr.write(`${JSON.stringify(value)}\n`);
6
+ }
7
+
8
+ /** Serves one parent-owned JSONL connection over the process standard streams. */
9
+ export async function startStdioServe(options: ServeOptions): Promise<ServeHandle> {
10
+ const pair = await startRelayPair({
11
+ ...options,
12
+ downstream: process.stdin,
13
+ downstreamSink: process.stdout,
14
+ onTransportError: writeDiagnostic,
15
+ });
16
+ return { close: pair.close, done: pair.done };
17
+ }
@@ -105,6 +105,16 @@ import {
105
105
  type FallbackAttemptToken,
106
106
  type FallbackTriggerClass,
107
107
  } from "@gajae-code/ai/utils/fallback-transport";
108
+ import {
109
+ BTW_MAX_ANSWER_UTF8_BYTES,
110
+ BTW_MAX_QUESTION_UTF8_BYTES,
111
+ BTW_STREAM_IDLE_TIMEOUT_MS,
112
+ BTW_STREAM_TOTAL_TIMEOUT_MS,
113
+ type BtwTextExchange,
114
+ boundBtwExchanges,
115
+ truncateUtf8,
116
+ utf8ByteLength,
117
+ } from "./btw-contract";
108
118
 
109
119
  export interface ForkContextSeedMetadata {
110
120
  sourceSessionId: string;
@@ -827,7 +837,6 @@ function isLocalModelEndpoint(model: Model | undefined): boolean {
827
837
  }
828
838
 
829
839
  const IRC_REPLY_MAX_BYTES = 4096;
830
- const BTW_ESTABLISHMENT_TIMEOUT_MS = 15_000;
831
840
 
832
841
  export type EphemeralTurnPurpose = "btw" | "background";
833
842
 
@@ -837,8 +846,34 @@ interface EphemeralTurnBaseArgs {
837
846
  signal?: AbortSignal;
838
847
  }
839
848
 
849
+ export interface BtwRoleTextMessage {
850
+ role: "user" | "assistant";
851
+ text: string;
852
+ }
853
+
854
+ export interface BtwConversationScope {
855
+ model: Model;
856
+ systemPrompt: string[];
857
+ messages: BtwRoleTextMessage[];
858
+ thinkingLevel: ThinkingLevel;
859
+ hideThinkingSummary: boolean;
860
+ serviceTier: ServiceTier | undefined;
861
+ credentialSessionId: string;
862
+ providerAffinitySessionId: string;
863
+ sideSessionId: string;
864
+ }
865
+
866
+ export interface BtwTurnCapture {
867
+ question: string;
868
+ scope: BtwConversationScope | undefined;
869
+ }
870
+
840
871
  export type EphemeralTurnArgs =
841
- | (EphemeralTurnBaseArgs & { purpose: "btw" })
872
+ | (Omit<EphemeralTurnBaseArgs, "promptText"> & {
873
+ purpose: "btw";
874
+ turn: BtwTurnCapture;
875
+ contextExchanges?: readonly BtwTextExchange[];
876
+ })
842
877
  | (EphemeralTurnBaseArgs & {
843
878
  purpose?: "background";
844
879
  /** Internal caller-supplied, non-persistent context such as the IRC roster. */
@@ -13993,167 +14028,127 @@ export class AgentSession {
13993
14028
  this.#ircRosterClaim = null;
13994
14029
  }
13995
14030
 
14031
+ createBtwConversationScope(instruction: string): BtwConversationScope {
14032
+ const model = this.model;
14033
+ if (!model) throw new Error("No active model on session");
14034
+ const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
14035
+ return {
14036
+ model,
14037
+ systemPrompt: [...this.systemPrompt, instruction],
14038
+ messages: this.#projectBtwVisibleText(this.buildDisplaySessionContext().messages),
14039
+ thinkingLevel: this.thinkingLevel ?? ThinkingLevel.Off,
14040
+ hideThinkingSummary: this.agent.hideThinkingSummary ?? false,
14041
+ serviceTier: this.serviceTier,
14042
+ credentialSessionId: providerAffinitySessionId,
14043
+ providerAffinitySessionId,
14044
+ sideSessionId: `${providerAffinitySessionId}:btw:${crypto.randomUUID()}`,
14045
+ };
14046
+ }
14047
+
14048
+ #projectBtwVisibleText(messages: readonly AgentMessage[]): BtwRoleTextMessage[] {
14049
+ const projected: BtwRoleTextMessage[] = [];
14050
+ for (const message of messages) {
14051
+ if (message.role !== "user" && message.role !== "assistant") continue;
14052
+ const text = (
14053
+ typeof message.content === "string"
14054
+ ? message.content
14055
+ : message.content
14056
+ .filter((block): block is TextContent => block.type === "text")
14057
+ .map(block => block.text)
14058
+ .join("")
14059
+ ).trim();
14060
+ if (!text) continue;
14061
+ projected.push({ role: message.role, text });
14062
+ }
14063
+ return projected;
14064
+ }
14065
+
14066
+ #buildBtwAssistantMessage(text: string): AssistantMessage {
14067
+ return {
14068
+ role: "assistant",
14069
+ content: [{ type: "text", text }],
14070
+ api: "btw",
14071
+ provider: "btw",
14072
+ model: "btw",
14073
+ usage: {
14074
+ input: 0,
14075
+ output: 0,
14076
+ cacheRead: 0,
14077
+ cacheWrite: 0,
14078
+ totalTokens: 0,
14079
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
14080
+ },
14081
+ stopReason: "stop",
14082
+ timestamp: 0,
14083
+ };
14084
+ }
13996
14085
  /**
13997
- * Run a single ephemeral side-channel turn against this session's current
13998
- * model + system prompt + history. No tools are used and session history is
13999
- * not modified. Background turns retain the IRC/session behavior used by
14000
- * autoreplies; `/btw` turns use an isolated committed-context policy.
14086
+ * Run a single ephemeral side-channel turn without modifying session history.
14087
+ * Background turns retain IRC/session behavior. `/btw` turns require a
14088
+ * pre-frozen, visible-text-only scope and bypass extension context transforms,
14089
+ * provider observability hooks, and session persistence surfaces.
14001
14090
  */
14002
14091
  async runEphemeralTurn(args: EphemeralTurnArgs): Promise<EphemeralTurnResult> {
14003
14092
  args.signal?.throwIfAborted();
14004
- const isBtw = args.purpose === "btw";
14005
- // `/btw` captures every provider-facing parent facet synchronously so an
14006
- // in-flight foreground mutation cannot create a mixed-generation request.
14007
- const btwSnapshot = isBtw
14008
- ? this.#buildBtwEphemeralSnapshot(
14009
- cloneJsonValueForForkSeed(this.buildDisplaySessionContext().messages),
14010
- args.promptText,
14011
- )
14012
- : undefined;
14013
- const model = this.model;
14014
- const systemPrompt = isBtw ? [...this.systemPrompt] : undefined;
14015
- const thinkingLevel = this.thinkingLevel;
14016
- const hideThinkingSummary = this.agent.hideThinkingSummary;
14017
- const serviceTier = this.serviceTier;
14018
- const providerAffinitySessionId = this.agent.providerSessionId ?? this.agent.sessionId ?? this.sessionId;
14019
- const sideSessionId = isBtw ? `${providerAffinitySessionId}:btw:${crypto.randomUUID()}` : undefined;
14020
- const backgroundArgs = isBtw ? undefined : args;
14021
- const callerOwnsRosterClaim = backgroundArgs?.ircRosterClaim !== undefined;
14022
- const rosterClaim = isBtw
14023
- ? null
14024
- : callerOwnsRosterClaim
14025
- ? backgroundArgs?.ircRosterClaim
14026
- : this.#claimIrcRosterCandidate();
14093
+ if (args.purpose === "btw") return await this.#runBtwTurn(args);
14094
+
14095
+ const callerOwnsRosterClaim = args.ircRosterClaim !== undefined;
14096
+ const rosterClaim = callerOwnsRosterClaim ? args.ircRosterClaim : this.#claimIrcRosterCandidate();
14027
14097
  const rosterClaimIsCurrent = () =>
14028
14098
  !rosterClaim || this.#isCurrentIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14029
- const prependMessagesValid = () => rosterClaimIsCurrent() && backgroundArgs?.prependMessagesValid?.() !== false;
14099
+ const prependMessagesValid = () => rosterClaimIsCurrent() && args.prependMessagesValid?.() !== false;
14030
14100
  const rosterMessage = !callerOwnsRosterClaim && rosterClaimIsCurrent() ? rosterClaim?.message : undefined;
14031
14101
  try {
14102
+ const model = this.model;
14032
14103
  if (!model) throw new Error("No active model on session");
14033
- const credentialSessionId = isBtw ? providerAffinitySessionId : this.sessionId;
14034
- const apiKey = await awaitEphemeralAbort(
14035
- this.#modelRegistry.getApiKey(model, credentialSessionId),
14036
- args.signal,
14037
- );
14104
+ const apiKey = await awaitEphemeralAbort(this.#modelRegistry.getApiKey(model, this.sessionId), args.signal);
14038
14105
  if (!apiKey) throw new Error(`No API key for ${model.provider}/${model.id}`);
14039
-
14040
14106
  const prependMessages = prependMessagesValid()
14041
- ? [...(rosterMessage ? [rosterMessage] : []), ...(backgroundArgs?.prependMessages ?? [])]
14107
+ ? [...(rosterMessage ? [rosterMessage] : []), ...(args.prependMessages ?? [])]
14042
14108
  : undefined;
14043
- let snapshot = btwSnapshot ?? this.#buildEphemeralSnapshot(args.promptText, prependMessages);
14109
+ let snapshot = this.#buildEphemeralSnapshot(args.promptText, prependMessages);
14044
14110
  let llmMessages = await awaitEphemeralAbort(this.convertMessagesToLlm(snapshot, args.signal), args.signal);
14045
- if (!isBtw && prependMessages && !prependMessagesValid()) {
14111
+ if (prependMessages && !prependMessagesValid()) {
14046
14112
  snapshot = this.#buildEphemeralSnapshot(args.promptText);
14047
14113
  llmMessages = await awaitEphemeralAbort(this.convertMessagesToLlm(snapshot, args.signal), args.signal);
14048
14114
  }
14049
- const context: Context = {
14050
- systemPrompt: systemPrompt ?? this.systemPrompt,
14051
- messages: llmMessages,
14052
- tools: [],
14053
- };
14054
-
14115
+ const context: Context = { systemPrompt: this.systemPrompt, messages: llmMessages, tools: [] };
14116
+ const ephemeralSessionId = crypto.randomUUID();
14117
+ const options = this.prepareSimpleStreamOptions(
14118
+ {
14119
+ apiKey,
14120
+ sessionId: ephemeralSessionId,
14121
+ metadata: buildSessionMetadata(
14122
+ ephemeralSessionId,
14123
+ model.provider,
14124
+ this.#modelRegistry.authStorage,
14125
+ this.sessionId,
14126
+ ),
14127
+ reasoning: toReasoningEffort(this.thinkingLevel),
14128
+ hideThinkingSummary: this.agent.hideThinkingSummary,
14129
+ serviceTier: this.serviceTier,
14130
+ signal: args.signal,
14131
+ toolChoice: "none",
14132
+ },
14133
+ model.provider,
14134
+ );
14135
+ args.signal?.throwIfAborted();
14055
14136
  let replyText = "";
14056
14137
  let assistantMessage: AssistantMessage | undefined;
14057
- if (isBtw) {
14058
- const establishmentAbort = new AbortController();
14059
- const requestSignal = args.signal
14060
- ? AbortSignal.any([args.signal, establishmentAbort.signal])
14061
- : establishmentAbort.signal;
14062
- const timeoutError = new Error(
14063
- `/btw provider did not produce an event within ${BTW_ESTABLISHMENT_TIMEOUT_MS / 1000} seconds`,
14064
- );
14065
- timeoutError.name = "TimeoutError";
14066
- let establishmentTimer: NodeJS.Timeout | undefined;
14067
- try {
14068
- requestSignal.throwIfAborted();
14069
- const options: SimpleStreamOptions = {
14070
- apiKey,
14071
- sessionId: sideSessionId!,
14072
- metadata: buildSessionMetadata(
14073
- sideSessionId!,
14074
- model.provider,
14075
- this.#modelRegistry.authStorage,
14076
- providerAffinitySessionId,
14077
- ),
14078
- reasoning: toReasoningEffort(thinkingLevel),
14079
- hideThinkingSummary,
14080
- serviceTier,
14081
- onPayload: this.#onPayload,
14082
- signal: requestSignal,
14083
- toolChoice: "none",
14084
- requestMaxRetries: 0,
14085
- streamMaxRetries: 0,
14086
- streamFirstEventTimeoutMs: 0,
14087
- };
14088
- establishmentTimer = setTimeout(
14089
- () => establishmentAbort.abort(timeoutError),
14090
- BTW_ESTABLISHMENT_TIMEOUT_MS,
14091
- );
14092
- establishmentTimer.unref?.();
14093
- const stream = streamSimple(model, context, options);
14094
- let receivedProviderEvent = false;
14095
- for await (const event of stream) {
14096
- if (!receivedProviderEvent && event.type !== "start") {
14097
- receivedProviderEvent = true;
14098
- clearTimeout(establishmentTimer);
14099
- establishmentTimer = undefined;
14100
- }
14101
- if (event.type === "text_delta") {
14102
- replyText += event.delta;
14103
- args.onTextDelta?.(event.delta);
14104
- } else if (event.type === "done") {
14105
- assistantMessage = event.message;
14106
- break;
14107
- } else if (event.type === "error") {
14108
- throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14109
- }
14110
- }
14111
- requestSignal.throwIfAborted();
14112
- } catch (error) {
14113
- if (establishmentAbort.signal.aborted && !args.signal?.aborted) throw timeoutError;
14114
- throw error;
14115
- } finally {
14116
- if (establishmentTimer) clearTimeout(establishmentTimer);
14117
- }
14118
- } else {
14119
- const ephemeralSessionId = crypto.randomUUID();
14120
- const options = this.prepareSimpleStreamOptions(
14121
- {
14122
- apiKey,
14123
- sessionId: ephemeralSessionId,
14124
- metadata: buildSessionMetadata(
14125
- ephemeralSessionId,
14126
- model.provider,
14127
- this.#modelRegistry.authStorage,
14128
- this.sessionId,
14129
- ),
14130
- reasoning: toReasoningEffort(thinkingLevel),
14131
- hideThinkingSummary,
14132
- serviceTier,
14133
- signal: args.signal,
14134
- toolChoice: "none",
14135
- },
14136
- model.provider,
14137
- );
14138
- args.signal?.throwIfAborted();
14139
- const stream = streamSimple(model, context, options);
14140
- for await (const event of stream) {
14141
- if (event.type === "text_delta") {
14142
- replyText += event.delta;
14143
- args.onTextDelta?.(event.delta);
14144
- } else if (event.type === "done") {
14145
- assistantMessage = event.message;
14146
- break;
14147
- } else if (event.type === "error") {
14148
- throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14149
- }
14138
+ for await (const event of streamSimple(model, context, options)) {
14139
+ if (event.type === "text_delta") {
14140
+ replyText += event.delta;
14141
+ args.onTextDelta?.(event.delta);
14142
+ } else if (event.type === "done") {
14143
+ assistantMessage = event.message;
14144
+ break;
14145
+ } else if (event.type === "error") {
14146
+ throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14150
14147
  }
14151
14148
  }
14152
-
14153
14149
  if (!assistantMessage) throw new Error("Ephemeral turn ended without a final message");
14154
14150
  args.signal?.throwIfAborted();
14155
14151
  if (
14156
- !isBtw &&
14157
14152
  !callerOwnsRosterClaim &&
14158
14153
  rosterClaim &&
14159
14154
  assistantMessage.stopReason !== "error" &&
@@ -14161,21 +14156,117 @@ export class AgentSession {
14161
14156
  ) {
14162
14157
  this.#commitIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14163
14158
  }
14164
- args.signal?.throwIfAborted();
14165
14159
  return { replyText: replyText.trim(), assistantMessage };
14166
14160
  } finally {
14167
- if (!isBtw && !callerOwnsRosterClaim && rosterClaim) {
14168
- this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14161
+ if (!callerOwnsRosterClaim && rosterClaim) this.#releaseIrcRosterClaim(rosterClaim.token, rosterClaim.epoch);
14162
+ }
14163
+ }
14164
+
14165
+ async #runBtwTurn(args: Extract<EphemeralTurnArgs, { purpose: "btw" }>): Promise<EphemeralTurnResult> {
14166
+ const model = args.turn.scope?.model;
14167
+ const credentialSessionId = args.turn.scope?.credentialSessionId;
14168
+ if (!model || !credentialSessionId) throw new Error("The /btw conversation scope was scrubbed.");
14169
+ if (utf8ByteLength(args.turn.question) > BTW_MAX_QUESTION_UTF8_BYTES) {
14170
+ throw new RangeError(`/btw questions are limited to ${BTW_MAX_QUESTION_UTF8_BYTES} UTF-8 bytes.`);
14171
+ }
14172
+ const apiKey = await awaitEphemeralAbort(this.#modelRegistry.getApiKey(model, credentialSessionId), args.signal);
14173
+ if (!apiKey) throw new Error(`No API key for ${model.provider}/${model.id}`);
14174
+ const scope = args.turn.scope;
14175
+ if (!scope) throw new Error("The /btw conversation scope was scrubbed.");
14176
+ const messages = scope.messages.map(message => ({
14177
+ role: message.role,
14178
+ content: [{ type: "text" as const, text: message.text }],
14179
+ })) as Message[];
14180
+ for (const exchange of boundBtwExchanges(args.contextExchanges ?? [])) {
14181
+ messages.push({
14182
+ role: "user",
14183
+ content: [{ type: "text", text: exchange.question }],
14184
+ } as Message);
14185
+ messages.push({
14186
+ role: "assistant",
14187
+ content: [{ type: "text", text: exchange.answer }],
14188
+ } as Message);
14189
+ }
14190
+ messages.push({
14191
+ role: "user",
14192
+ content: [{ type: "text", text: args.turn.question }],
14193
+ } as Message);
14194
+ const context: Context = { systemPrompt: scope.systemPrompt, messages, tools: [] };
14195
+ const timeoutAbort = new AbortController();
14196
+ const requestSignal = args.signal ? AbortSignal.any([args.signal, timeoutAbort.signal]) : timeoutAbort.signal;
14197
+ const options: SimpleStreamOptions = {
14198
+ apiKey,
14199
+ sessionId: scope.sideSessionId,
14200
+ reasoning: toReasoningEffort(scope.thinkingLevel),
14201
+ hideThinkingSummary: scope.hideThinkingSummary,
14202
+ serviceTier: scope.serviceTier,
14203
+ signal: requestSignal,
14204
+ toolChoice: "none",
14205
+ requestMaxRetries: 0,
14206
+ streamMaxRetries: 0,
14207
+ streamFirstEventTimeoutMs: 0,
14208
+ };
14209
+ const iterator = streamSimple(scope.model, context, options)[Symbol.asyncIterator]();
14210
+ let replyText = "";
14211
+ let completed = false;
14212
+ let active = true;
14213
+ let onTextDelta = args.onTextDelta;
14214
+ let idleTimer: NodeJS.Timeout | undefined;
14215
+ const timeout = (message: string) => {
14216
+ const error = new Error(message);
14217
+ error.name = "TimeoutError";
14218
+ timeoutAbort.abort(error);
14219
+ };
14220
+ const resetIdleTimer = () => {
14221
+ if (idleTimer) clearTimeout(idleTimer);
14222
+ idleTimer = setTimeout(
14223
+ () => timeout(`/btw provider was idle for ${BTW_STREAM_IDLE_TIMEOUT_MS / 1000} seconds`),
14224
+ BTW_STREAM_IDLE_TIMEOUT_MS,
14225
+ );
14226
+ idleTimer.unref?.();
14227
+ };
14228
+ const totalTimer = setTimeout(
14229
+ () => timeout(`/btw provider exceeded ${BTW_STREAM_TOTAL_TIMEOUT_MS / 1000} seconds`),
14230
+ BTW_STREAM_TOTAL_TIMEOUT_MS,
14231
+ );
14232
+ totalTimer.unref?.();
14233
+ resetIdleTimer();
14234
+ const consume = async () => {
14235
+ while (active) {
14236
+ const result = await iterator.next();
14237
+ if (result.done || !active) break;
14238
+ resetIdleTimer();
14239
+ const event = result.value;
14240
+ if (event.type === "text_delta") {
14241
+ const bounded = truncateUtf8(replyText + event.delta, BTW_MAX_ANSWER_UTF8_BYTES);
14242
+ const delta = bounded.slice(replyText.length);
14243
+ replyText = bounded;
14244
+ if (delta) onTextDelta?.(delta);
14245
+ } else if (event.type === "done") {
14246
+ completed = true;
14247
+ break;
14248
+ } else if (event.type === "error") {
14249
+ throw new Error(event.error.errorMessage || "Ephemeral turn failed");
14250
+ }
14169
14251
  }
14252
+ };
14253
+ try {
14254
+ await awaitEphemeralAbort(consume(), requestSignal);
14255
+ requestSignal.throwIfAborted();
14256
+ if (!completed) throw new Error("Ephemeral turn ended without a final message");
14257
+ const finalText = replyText.trim();
14258
+ return { replyText: finalText, assistantMessage: this.#buildBtwAssistantMessage(finalText) };
14259
+ } finally {
14260
+ active = false;
14261
+ onTextDelta = undefined;
14262
+ replyText = "";
14263
+ context.messages = [];
14264
+ if (idleTimer) clearTimeout(idleTimer);
14265
+ clearTimeout(totalTimer);
14266
+ void iterator.return?.().catch(() => undefined);
14170
14267
  }
14171
14268
  }
14172
14269
 
14173
- /** Build a `/btw` snapshot from an already-cloned committed context. */
14174
- #buildBtwEphemeralSnapshot(messages: AgentMessage[], promptText: string): AgentMessage[] {
14175
- messages.push(this.#buildEphemeralPromptMessage(promptText));
14176
- return messages;
14177
- }
14178
-
14179
14270
  /** Build a background snapshot with in-flight assistant and optional context. */
14180
14271
  #buildEphemeralSnapshot(promptText: string, prependMessages?: AgentMessage[]): AgentMessage[] {
14181
14272
  const messages = [...this.messages];