@huynle/hub 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
// CodexSessions: the Codex spoke's view of the threads it is attached to (P7-T7a; spike v0hsdqhm). The spoke is one
|
|
2
|
+
// client of a Codex app-server among others (the TUI, the desktop app): it reports each thread it attached to as a
|
|
3
|
+
// hub session (ses_cx<thread uuid>), turns the app-server's notifications into the harness event subset, and serves
|
|
4
|
+
// the hub's ops through app-server methods.
|
|
5
|
+
//
|
|
6
|
+
// Events (protocol harness-events.ts):
|
|
7
|
+
// - thread/status/changed active|idle → session.status busy|idle; turn/started → session.execution.started;
|
|
8
|
+
// turn/completed → the reply (session.text.ended: the turn's last agent message), then succeeded, failed or
|
|
9
|
+
// interrupted, as Codex confirms it (the spoke announces turnEvents: the hub trusts its rpc evidence for idle).
|
|
10
|
+
// - item/started userMessage → session.inbox.delivered: origin "hub" for input the spoke sent for a hub message
|
|
11
|
+
// (matched by clientUserMessageId, which Codex echoes as the item's clientId), else "user" (typed in the TUI, or
|
|
12
|
+
// the hub's own turn/steer ops, which are the user's).
|
|
13
|
+
// - tool items (commands, file changes, MCP and dynamic tools, web searches) → session.tool.called/success/failed,
|
|
14
|
+
// compacted to an ID and a short summary.
|
|
15
|
+
// - thread/name/updated → session.renamed; thread/archived|deleted → session.deleted; closed → no longer reported.
|
|
16
|
+
//
|
|
17
|
+
// Permission: an approval request (item/commandExecution|fileChange/requestApproval) goes to every client of the
|
|
18
|
+
// thread and the first answer wins. The spoke asks the hub (permission.asked) and answers with the hub's decision;
|
|
19
|
+
// when another client answered first (serverRequest/resolved), the request is gone (permission.replied without a
|
|
20
|
+
// reply, and the hub's later answer gets {gone: true}). Other server requests (forms, MCP elicitations) are left to
|
|
21
|
+
// the other clients.
|
|
22
|
+
//
|
|
23
|
+
// Deliver (each messageID once: HarnessSpoke's DeliveryLedger, since Codex's queue does not deduplicate):
|
|
24
|
+
// wake false → thread/inject_items (into the context, no turn): "queued". Wake on a busy thread → turn/steer into
|
|
25
|
+
// the running turn ("steer"), or with delivery "queue" held until the turn ends and then started as the next turn
|
|
26
|
+
// ("queued"); on an idle thread → turn/start ("turn").
|
|
27
|
+
import { statSync } from "node:fs"
|
|
28
|
+
import path from "node:path"
|
|
29
|
+
import { randomBytes } from "node:crypto"
|
|
30
|
+
import {
|
|
31
|
+
HARNESS_ERROR_MAX,
|
|
32
|
+
HARNESS_SUMMARY_MAX,
|
|
33
|
+
HARNESS_TEXT_MAX,
|
|
34
|
+
HARNESS_TITLE_MAX,
|
|
35
|
+
type CreateRequest,
|
|
36
|
+
type DeliverRequest,
|
|
37
|
+
type HarnessEvent,
|
|
38
|
+
type PermissionDecision,
|
|
39
|
+
type PromptImage,
|
|
40
|
+
type ResultFor,
|
|
41
|
+
type ServerRequest,
|
|
42
|
+
type SessionInfo,
|
|
43
|
+
type TurnResult,
|
|
44
|
+
} from "../../protocol/src/index.ts"
|
|
45
|
+
import { deliverText, type Admission } from "../../spoke-sdk/src/index.ts"
|
|
46
|
+
import { codexSessionID, codexThreadID, plainID, requestKey } from "./ids.ts"
|
|
47
|
+
import { LOCAL_ERROR, RpcError, type RpcID } from "./jsonrpc.ts"
|
|
48
|
+
|
|
49
|
+
/** What CodexSessions needs of the hub connection (HarnessSpoke). */
|
|
50
|
+
export interface SpokePort {
|
|
51
|
+
upsertSession(info: SessionInfo): void
|
|
52
|
+
endSession(id: string): void
|
|
53
|
+
emit(event: HarnessEvent): boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One app-server connection (initialized), named so its request IDs stay apart from another's. */
|
|
57
|
+
export interface AppServerPort {
|
|
58
|
+
readonly name: string
|
|
59
|
+
request(method: string, params?: unknown): Promise<any>
|
|
60
|
+
respond(id: RpcID, result: unknown): void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A thread as the app-server describes it (Thread, from thread/start, thread/resume, thread/read). */
|
|
64
|
+
export interface ThreadDescription {
|
|
65
|
+
readonly id: string
|
|
66
|
+
readonly cwd?: string
|
|
67
|
+
readonly name?: string | null
|
|
68
|
+
readonly preview?: string | null
|
|
69
|
+
readonly status?: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface SessionsOptions {
|
|
73
|
+
readonly spoke: SpokePort
|
|
74
|
+
/** The app-server the spoke starts its own threads on (create). */
|
|
75
|
+
readonly createServer: () => Promise<AppServerPort>
|
|
76
|
+
/** Extra thread/start parameters for created threads (e.g. approvalPolicy in tests). */
|
|
77
|
+
readonly threadDefaults?: Readonly<Record<string, unknown>>
|
|
78
|
+
/** How long the turn op waits for the turn to end (default 10 min). */
|
|
79
|
+
readonly turnTimeoutMs?: number
|
|
80
|
+
/** The project (workspace root) of a directory; default: the nearest directory with .git. */
|
|
81
|
+
readonly projectOf?: (cwd: string) => string | undefined
|
|
82
|
+
readonly now?: () => number
|
|
83
|
+
readonly log?: (line: string) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60_000
|
|
87
|
+
const DEFAULT_TITLE = "Codex session"
|
|
88
|
+
const TOOL_ITEMS = new Set(["commandExecution", "fileChange", "mcpToolCall", "dynamicToolCall", "webSearch"])
|
|
89
|
+
const APPROVALS: Record<string, "shell" | "edit"> = { "item/commandExecution/requestApproval": "shell", "item/fileChange/requestApproval": "edit" }
|
|
90
|
+
const RECENT_TURNS = 50
|
|
91
|
+
|
|
92
|
+
type Origin = "user" | "hub"
|
|
93
|
+
type TurnEnd = { readonly status: string; readonly error?: string }
|
|
94
|
+
|
|
95
|
+
interface TurnWaiter {
|
|
96
|
+
resolve(end: TurnEnd): void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface Queued {
|
|
100
|
+
readonly inboxID: string
|
|
101
|
+
readonly text: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Thread {
|
|
105
|
+
readonly id: string
|
|
106
|
+
readonly hubID: string
|
|
107
|
+
readonly conn: AppServerPort
|
|
108
|
+
readonly cwd: string
|
|
109
|
+
title: string
|
|
110
|
+
busy: boolean
|
|
111
|
+
turnID?: string
|
|
112
|
+
reply?: { readonly id: string; readonly text: string }
|
|
113
|
+
/** Tool items of the running turns (for approvals' resources and the tools' end). */
|
|
114
|
+
readonly items: Map<string, { readonly type: string; readonly paths: readonly string[] }>
|
|
115
|
+
/** clientUserMessageIds of input the spoke sent → whose it is. */
|
|
116
|
+
readonly inputs: Map<string, Origin>
|
|
117
|
+
/** Hub messages waiting for the running turn to end (delivery "queue"). */
|
|
118
|
+
readonly queue: Queued[]
|
|
119
|
+
/** Hub messages injected without waking (messageID → inboxID). */
|
|
120
|
+
readonly injected: Map<string, string>
|
|
121
|
+
readonly waiters: Map<string, TurnWaiter>
|
|
122
|
+
readonly ended: Map<string, TurnEnd>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Approval {
|
|
126
|
+
readonly conn: AppServerPort
|
|
127
|
+
readonly rpcID: RpcID
|
|
128
|
+
readonly threadID: string
|
|
129
|
+
readonly available?: readonly unknown[]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
133
|
+
const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined)
|
|
134
|
+
const cut = (text: string, max: number) => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
|
|
135
|
+
const inboxID = () => `inb_${randomBytes(9).toString("hex")}`
|
|
136
|
+
const textInput = (text: string) => ({ type: "text", text, text_elements: [] })
|
|
137
|
+
|
|
138
|
+
/** The nearest directory from `cwd` up that holds .git (a repository or worktree root). */
|
|
139
|
+
export function gitRoot(cwd: string): string | undefined {
|
|
140
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
141
|
+
try {
|
|
142
|
+
statSync(path.join(dir, ".git"))
|
|
143
|
+
return dir
|
|
144
|
+
} catch {}
|
|
145
|
+
if (path.dirname(dir) === dir) return undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A command as the user typed it: Codex wraps it in `/bin/bash -lc '<command>'`. */
|
|
150
|
+
export function unwrapCommand(command: string): string {
|
|
151
|
+
const wrapped = /^(?:\S*\/)?(?:bash|zsh|sh)\s+-l?c\s+'([\s\S]*)'$/.exec(command.trim())
|
|
152
|
+
return wrapped ? wrapped[1]!.replace(/'\\''/g, "'") : command.trim()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A tool item's short summary (no raw input or output). */
|
|
156
|
+
export function toolSummary(item: Record<string, any>): string | undefined {
|
|
157
|
+
let summary: string | undefined
|
|
158
|
+
switch (item.type) {
|
|
159
|
+
case "commandExecution":
|
|
160
|
+
summary = typeof item.command === "string" ? unwrapCommand(item.command) : undefined
|
|
161
|
+
break
|
|
162
|
+
case "fileChange":
|
|
163
|
+
summary = changedPaths(item).join(", ") || undefined
|
|
164
|
+
break
|
|
165
|
+
case "mcpToolCall":
|
|
166
|
+
summary = [str(item.server), str(item.tool)].filter(Boolean).join("_") || undefined
|
|
167
|
+
break
|
|
168
|
+
case "dynamicToolCall":
|
|
169
|
+
summary = str(item.tool)
|
|
170
|
+
break
|
|
171
|
+
case "webSearch":
|
|
172
|
+
summary = str(item.query)
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
return summary === undefined ? undefined : cut(summary.replace(/\s+/g, " "), HARNESS_SUMMARY_MAX)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const changedPaths = (item: Record<string, any>): string[] =>
|
|
179
|
+
Array.isArray(item.changes) ? item.changes.flatMap((c: unknown) => (isObj(c) && typeof c.path === "string" ? [c.path] : [])) : []
|
|
180
|
+
|
|
181
|
+
/** The approval decision for the hub's decision, among those the request offers (all, when it lists none). */
|
|
182
|
+
export function approvalDecision(decision: PermissionDecision, available: readonly unknown[] | undefined): unknown {
|
|
183
|
+
const offers = (d: string) => !available || available.includes(d)
|
|
184
|
+
switch (decision) {
|
|
185
|
+
case "once":
|
|
186
|
+
return "accept"
|
|
187
|
+
case "always": {
|
|
188
|
+
if (offers("acceptForSession")) return "acceptForSession"
|
|
189
|
+
const amendment = available?.find((d) => isObj(d) && "acceptWithExecpolicyAmendment" in d)
|
|
190
|
+
return amendment ?? "accept"
|
|
191
|
+
}
|
|
192
|
+
case "reject":
|
|
193
|
+
return offers("decline") ? "decline" : "cancel"
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export class CodexSessions {
|
|
198
|
+
readonly #spoke: SpokePort
|
|
199
|
+
readonly #createServer: SessionsOptions["createServer"]
|
|
200
|
+
readonly #threadDefaults: Readonly<Record<string, unknown>>
|
|
201
|
+
readonly #turnTimeoutMs: number
|
|
202
|
+
readonly #projectOf: (cwd: string) => string | undefined
|
|
203
|
+
readonly #now: () => number
|
|
204
|
+
readonly #log: (line: string) => void
|
|
205
|
+
/** By native thread ID. */
|
|
206
|
+
readonly #threads = new Map<string, Thread>()
|
|
207
|
+
/** By hub request ID (requestKey). */
|
|
208
|
+
readonly #approvals = new Map<string, Approval>()
|
|
209
|
+
|
|
210
|
+
constructor(options: SessionsOptions) {
|
|
211
|
+
this.#spoke = options.spoke
|
|
212
|
+
this.#createServer = options.createServer
|
|
213
|
+
this.#threadDefaults = options.threadDefaults ?? {}
|
|
214
|
+
this.#turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS
|
|
215
|
+
this.#projectOf = options.projectOf ?? gitRoot
|
|
216
|
+
this.#now = options.now ?? Date.now
|
|
217
|
+
this.#log = options.log ?? (() => {})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
has(sessionID: string): boolean {
|
|
221
|
+
const id = codexThreadID(sessionID)
|
|
222
|
+
return id !== undefined && this.#threads.has(id)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Whether the spoke reports this thread (on any connection). */
|
|
226
|
+
hasThread(threadID: string): boolean {
|
|
227
|
+
return this.#threads.has(threadID.toLowerCase())
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Reports a thread the spoke is subscribed to on `conn` (after thread/start or thread/resume). */
|
|
231
|
+
attach(conn: AppServerPort, thread: ThreadDescription, title?: string): string {
|
|
232
|
+
const id = thread.id.toLowerCase()
|
|
233
|
+
const known = this.#threads.get(id)
|
|
234
|
+
if (known) return known.hubID
|
|
235
|
+
const entry: Thread = {
|
|
236
|
+
id,
|
|
237
|
+
hubID: codexSessionID(id),
|
|
238
|
+
conn,
|
|
239
|
+
cwd: thread.cwd || "/",
|
|
240
|
+
title: cut((title ?? str(thread.name) ?? str(thread.preview)?.replace(/\s+/g, " ").trim()) || DEFAULT_TITLE, HARNESS_TITLE_MAX),
|
|
241
|
+
busy: false,
|
|
242
|
+
items: new Map(),
|
|
243
|
+
inputs: new Map(),
|
|
244
|
+
queue: [],
|
|
245
|
+
injected: new Map(),
|
|
246
|
+
waiters: new Map(),
|
|
247
|
+
ended: new Map(),
|
|
248
|
+
}
|
|
249
|
+
this.#threads.set(id, entry)
|
|
250
|
+
this.#report(entry)
|
|
251
|
+
this.#emit({ type: "session.created", data: { sessionID: entry.hubID, title: entry.title } })
|
|
252
|
+
if (isObj(thread.status) && thread.status.type === "active") this.#setBusy(entry, true)
|
|
253
|
+
return entry.hubID
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The connection closed: its threads are no longer reported, its requests are gone. */
|
|
257
|
+
connectionClosed(conn: AppServerPort): void {
|
|
258
|
+
for (const thread of [...this.#threads.values()]) if (thread.conn === conn) this.#end(thread, false)
|
|
259
|
+
for (const [key, approval] of this.#approvals) if (approval.conn === conn) this.#approvals.delete(key)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
notification(conn: AppServerPort, method: string, params: unknown): void {
|
|
263
|
+
if (!isObj(params) || typeof params.threadId !== "string") return
|
|
264
|
+
const thread = this.#threads.get(params.threadId.toLowerCase())
|
|
265
|
+
if (!thread || thread.conn !== conn) return
|
|
266
|
+
switch (method) {
|
|
267
|
+
case "thread/status/changed": {
|
|
268
|
+
const type = isObj(params.status) ? params.status.type : undefined
|
|
269
|
+
if (type === "active") this.#setBusy(thread, true)
|
|
270
|
+
else if (type === "idle" || type === "systemError") this.#setBusy(thread, false)
|
|
271
|
+
else if (type === "notLoaded") this.#end(thread, false)
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
case "turn/started": {
|
|
275
|
+
const turnID = isObj(params.turn) ? str(params.turn.id) : undefined
|
|
276
|
+
if (!turnID) return
|
|
277
|
+
thread.turnID = turnID
|
|
278
|
+
thread.reply = undefined
|
|
279
|
+
this.#emit({ type: "session.execution.started", data: { sessionID: thread.hubID, turnID: plainID(turnID) } })
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
case "turn/completed":
|
|
283
|
+
return this.#turnCompleted(thread, params.turn)
|
|
284
|
+
case "item/started":
|
|
285
|
+
return this.#itemStarted(thread, params)
|
|
286
|
+
case "item/completed":
|
|
287
|
+
return this.#itemCompleted(thread, params)
|
|
288
|
+
case "thread/name/updated": {
|
|
289
|
+
const name = str(params.threadName)?.trim()
|
|
290
|
+
if (!name) return
|
|
291
|
+
thread.title = cut(name, HARNESS_TITLE_MAX)
|
|
292
|
+
this.#report(thread)
|
|
293
|
+
this.#emit({ type: "session.renamed", data: { sessionID: thread.hubID, title: thread.title } })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
case "thread/archived":
|
|
297
|
+
case "thread/deleted":
|
|
298
|
+
return this.#end(thread, true)
|
|
299
|
+
case "thread/closed":
|
|
300
|
+
return this.#end(thread, false)
|
|
301
|
+
case "serverRequest/resolved": {
|
|
302
|
+
const rpcID = params.requestId
|
|
303
|
+
if (typeof rpcID !== "number" && typeof rpcID !== "string") return
|
|
304
|
+
const key = requestKey(conn.name, rpcID)
|
|
305
|
+
if (!this.#approvals.delete(key)) return
|
|
306
|
+
this.#emit({ type: "permission.replied", data: { sessionID: thread.hubID, requestID: key } })
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** A request the app-server sent its clients; true when the spoke took it up (an approval for a known thread). */
|
|
313
|
+
serverRequest(conn: AppServerPort, rpcID: RpcID, method: string, params: unknown): boolean {
|
|
314
|
+
const action = APPROVALS[method]
|
|
315
|
+
if (!action || !isObj(params) || typeof params.threadId !== "string") return false
|
|
316
|
+
const thread = this.#threads.get(params.threadId.toLowerCase())
|
|
317
|
+
if (!thread || thread.conn !== conn) return false
|
|
318
|
+
const key = requestKey(conn.name, rpcID)
|
|
319
|
+
const itemID = str(params.itemId)
|
|
320
|
+
const item = itemID ? thread.items.get(itemID) : undefined
|
|
321
|
+
const resources =
|
|
322
|
+
action === "shell"
|
|
323
|
+
? [typeof params.command === "string" ? params.command : ""].filter(Boolean)
|
|
324
|
+
: item?.paths.length
|
|
325
|
+
? [...item.paths]
|
|
326
|
+
: [str(params.grantRoot) ?? thread.cwd]
|
|
327
|
+
this.#approvals.set(key, { conn, rpcID, threadID: thread.id, ...(Array.isArray(params.availableDecisions) ? { available: params.availableDecisions } : {}) })
|
|
328
|
+
const turnID = str(params.turnId) ?? thread.turnID
|
|
329
|
+
this.#emit({
|
|
330
|
+
type: "permission.asked",
|
|
331
|
+
data: {
|
|
332
|
+
sessionID: thread.hubID,
|
|
333
|
+
id: key,
|
|
334
|
+
action,
|
|
335
|
+
resources: resources.slice(0, 100).map((r) => cut(r, 4000)),
|
|
336
|
+
...(itemID && turnID ? { source: { type: "tool" as const, messageID: plainID(turnID), id: plainID(itemID) } } : {}),
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
return true
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---- ops ------------------------------------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
async permission(req: Extract<ServerRequest, { op: "permission" }>): Promise<ResultFor["permission"]> {
|
|
345
|
+
const approval = this.#approvals.get(req.requestID)
|
|
346
|
+
const thread = this.#thread(req.sessionID)
|
|
347
|
+
if (!approval || approval.threadID !== thread.id) return { gone: true }
|
|
348
|
+
this.#approvals.delete(req.requestID)
|
|
349
|
+
approval.conn.respond(approval.rpcID, { decision: approvalDecision(req.decision, approval.available) })
|
|
350
|
+
this.#emit({ type: "permission.replied", data: { sessionID: thread.hubID, requestID: req.requestID, reply: req.decision } })
|
|
351
|
+
return {}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async turn(req: Extract<ServerRequest, { op: "turn" }>): Promise<TurnResult> {
|
|
355
|
+
const thread = this.#thread(req.sessionID)
|
|
356
|
+
if (thread.turnID) throw new Error("The Codex session is busy (a turn is running); steer it instead")
|
|
357
|
+
const turnID = await this.#startTurn(thread, input(req.text, req.images), "user")
|
|
358
|
+
const end = await this.#waitTurn(thread, turnID)
|
|
359
|
+
const reply = thread.reply?.text ?? ""
|
|
360
|
+
if (!end) return { outcome: "timeout", reply }
|
|
361
|
+
if (end.status === "completed") return { outcome: "succeeded", reply }
|
|
362
|
+
return { outcome: end.status === "interrupted" ? "interrupted" : "failed", message: end.error ?? (end.status === "interrupted" ? "Interrupted" : "The turn failed"), reply }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async steer(req: Extract<ServerRequest, { op: "steer" }>): Promise<ResultFor["steer"]> {
|
|
366
|
+
const thread = this.#thread(req.sessionID)
|
|
367
|
+
const id = inboxID()
|
|
368
|
+
await this.#steerOrStart(thread, input(req.text, req.images), "user", id)
|
|
369
|
+
return { inboxID: id }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async interrupt(req: Extract<ServerRequest, { op: "interrupt" }>): Promise<ResultFor["interrupt"]> {
|
|
373
|
+
const thread = this.#thread(req.sessionID)
|
|
374
|
+
if (thread.turnID) await thread.conn.request("turn/interrupt", { threadId: thread.id, turnId: thread.turnID })
|
|
375
|
+
return {}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async stop(req: Extract<ServerRequest, { op: "stop" }>): Promise<ResultFor["stop"]> {
|
|
379
|
+
const thread = this.#thread(req.sessionID)
|
|
380
|
+
if (!thread.turnID) return { stopped: [] }
|
|
381
|
+
await thread.conn.request("turn/interrupt", { threadId: thread.id, turnId: thread.turnID })
|
|
382
|
+
return { stopped: [thread.hubID] }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async deliver(req: DeliverRequest): Promise<Admission> {
|
|
386
|
+
const thread = this.#thread(req.sessionID)
|
|
387
|
+
const injected = thread.injected.get(req.messageID)
|
|
388
|
+
const text = injected ? `[hub] Message ${req.messageID} was delivered to this session above; read it and act on it.` : deliverText(req)
|
|
389
|
+
if (!req.wake) {
|
|
390
|
+
const id = inboxID()
|
|
391
|
+
await thread.conn.request("thread/inject_items", { threadId: thread.id, items: [{ type: "message", role: "user", content: [{ type: "input_text", text }] }] })
|
|
392
|
+
thread.injected.set(req.messageID, id)
|
|
393
|
+
this.#emit({ type: "session.inbox.delivered", data: { sessionID: thread.hubID, inboxID: id, origin: "hub" } })
|
|
394
|
+
return { result: { delivered: "queued", inboxID: id }, resumed: false }
|
|
395
|
+
}
|
|
396
|
+
const id = inboxID()
|
|
397
|
+
if (thread.turnID && req.meta.delivery === "queue") {
|
|
398
|
+
thread.queue.push({ inboxID: id, text })
|
|
399
|
+
return { result: { delivered: "queued", inboxID: id }, resumed: true }
|
|
400
|
+
}
|
|
401
|
+
const how = await this.#steerOrStart(thread, [textInput(text)], "hub", id)
|
|
402
|
+
return { result: { delivered: how, inboxID: id }, resumed: true }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async create(req: CreateRequest): Promise<ResultFor["create"]> {
|
|
406
|
+
if (req.agent !== undefined) throw new Error(`Codex has no agents (asked for ${JSON.stringify(req.agent)})`)
|
|
407
|
+
if (!path.isAbsolute(req.directory)) throw new Error(`The directory must be an absolute path (got ${JSON.stringify(req.directory)})`)
|
|
408
|
+
let isDir = false
|
|
409
|
+
try {
|
|
410
|
+
isDir = statSync(req.directory).isDirectory()
|
|
411
|
+
} catch {}
|
|
412
|
+
if (!isDir) throw new Error(`${req.directory} does not exist or is not a directory`)
|
|
413
|
+
const conn = await this.#createServer()
|
|
414
|
+
const started = await conn.request("thread/start", { ...this.#threadDefaults, cwd: req.directory, ...(req.model ? { model: req.model.modelID } : {}) })
|
|
415
|
+
const thread = isObj(started) && isObj(started.thread) ? (started.thread as unknown as ThreadDescription) : undefined
|
|
416
|
+
if (!thread || typeof thread.id !== "string") throw new Error("Codex started no thread (thread/start answered without one)")
|
|
417
|
+
const title = req.title?.trim() ? cut(req.title.trim(), HARNESS_TITLE_MAX) : undefined
|
|
418
|
+
const sessionID = this.attach(conn, { ...thread, cwd: thread.cwd || req.directory }, title)
|
|
419
|
+
if (title) await conn.request("thread/name/set", { threadId: thread.id, name: title }).catch((cause) => this.#log(`thread/name/set: ${message(cause)}`))
|
|
420
|
+
return { sessionID }
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---- internals ------------------------------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
#thread(sessionID: string): Thread {
|
|
426
|
+
const id = codexThreadID(sessionID)
|
|
427
|
+
const thread = id === undefined ? undefined : this.#threads.get(id)
|
|
428
|
+
if (!thread) throw new Error(`Unknown Codex session ${sessionID}`)
|
|
429
|
+
return thread
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
#emit(event: HarnessEvent): void {
|
|
433
|
+
this.#spoke.emit(event)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#report(thread: Thread): void {
|
|
437
|
+
const project = this.#projectOf(thread.cwd)
|
|
438
|
+
this.#spoke.upsertSession({
|
|
439
|
+
id: thread.hubID,
|
|
440
|
+
title: thread.title,
|
|
441
|
+
directory: thread.cwd,
|
|
442
|
+
updated: this.#now(),
|
|
443
|
+
owned: true,
|
|
444
|
+
...(project ? { project, projectName: path.basename(project) } : {}),
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#setBusy(thread: Thread, busy: boolean): void {
|
|
449
|
+
if (thread.busy === busy) return
|
|
450
|
+
thread.busy = busy
|
|
451
|
+
this.#emit({ type: "session.status", data: { sessionID: thread.hubID, status: { type: busy ? "busy" : "idle" } } })
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
#end(thread: Thread, deleted: boolean): void {
|
|
455
|
+
this.#threads.delete(thread.id)
|
|
456
|
+
for (const [key, approval] of this.#approvals) if (approval.threadID === thread.id) this.#approvals.delete(key)
|
|
457
|
+
for (const waiter of thread.waiters.values()) waiter.resolve({ status: "failed", error: "The Codex session ended" })
|
|
458
|
+
thread.waiters.clear()
|
|
459
|
+
if (deleted) this.#emit({ type: "session.deleted", data: { sessionID: thread.hubID } })
|
|
460
|
+
this.#spoke.endSession(thread.hubID)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async #startTurn(thread: Thread, items: unknown[], origin: Origin, id = inboxID()): Promise<string> {
|
|
464
|
+
thread.inputs.set(id, origin)
|
|
465
|
+
let started: unknown
|
|
466
|
+
try {
|
|
467
|
+
started = await thread.conn.request("turn/start", { threadId: thread.id, input: items, clientUserMessageId: id })
|
|
468
|
+
} catch (cause) {
|
|
469
|
+
thread.inputs.delete(id)
|
|
470
|
+
throw cause
|
|
471
|
+
}
|
|
472
|
+
const turnID = isObj(started) && isObj(started.turn) ? str(started.turn.id) : undefined
|
|
473
|
+
if (!turnID) throw new Error("Codex started no turn (turn/start answered without one)")
|
|
474
|
+
if (!thread.ended.has(turnID)) thread.turnID = turnID
|
|
475
|
+
return turnID
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Into the running turn when there is one ("steer"), else a new turn ("turn"); a lost race starts a turn. */
|
|
479
|
+
async #steerOrStart(thread: Thread, items: unknown[], origin: Origin, id: string): Promise<"steer" | "turn"> {
|
|
480
|
+
if (thread.turnID) {
|
|
481
|
+
thread.inputs.set(id, origin)
|
|
482
|
+
try {
|
|
483
|
+
await thread.conn.request("turn/steer", { threadId: thread.id, expectedTurnId: thread.turnID, input: items, clientUserMessageId: id })
|
|
484
|
+
return "steer"
|
|
485
|
+
} catch (cause) {
|
|
486
|
+
thread.inputs.delete(id)
|
|
487
|
+
if (!(cause instanceof RpcError) || cause.code === LOCAL_ERROR) throw cause
|
|
488
|
+
this.#log(`turn/steer refused (${cause.message}); starting a turn`)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
await this.#startTurn(thread, items, origin, id)
|
|
492
|
+
return "turn"
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
#waitTurn(thread: Thread, turnID: string): Promise<TurnEnd | undefined> {
|
|
496
|
+
const ended = thread.ended.get(turnID)
|
|
497
|
+
if (ended) return Promise.resolve(ended)
|
|
498
|
+
return new Promise((resolve) => {
|
|
499
|
+
const timer = setTimeout(() => {
|
|
500
|
+
thread.waiters.delete(turnID)
|
|
501
|
+
resolve(undefined)
|
|
502
|
+
}, this.#turnTimeoutMs)
|
|
503
|
+
thread.waiters.set(turnID, {
|
|
504
|
+
resolve: (end) => {
|
|
505
|
+
clearTimeout(timer)
|
|
506
|
+
resolve(end)
|
|
507
|
+
},
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
#turnCompleted(thread: Thread, turn: unknown): void {
|
|
513
|
+
if (!isObj(turn)) return
|
|
514
|
+
const turnID = str(turn.id)
|
|
515
|
+
if (!turnID) return
|
|
516
|
+
const status = typeof turn.status === "string" ? turn.status : "completed"
|
|
517
|
+
const error = isObj(turn.error) && typeof turn.error.message === "string" ? cut(turn.error.message, HARNESS_ERROR_MAX) : undefined
|
|
518
|
+
const end: TurnEnd = { status, ...(error !== undefined ? { error } : {}) }
|
|
519
|
+
if (thread.turnID === turnID) thread.turnID = undefined
|
|
520
|
+
thread.ended.set(turnID, end)
|
|
521
|
+
while (thread.ended.size > RECENT_TURNS) thread.ended.delete(thread.ended.keys().next().value!)
|
|
522
|
+
const sessionID = thread.hubID
|
|
523
|
+
if (thread.reply) this.#emit({ type: "session.text.ended", data: { sessionID, assistantMessageID: plainID(thread.reply.id), ordinal: 0, text: cut(thread.reply.text, HARNESS_TEXT_MAX) } })
|
|
524
|
+
const data = { sessionID, turnID: plainID(turnID) }
|
|
525
|
+
if (status === "interrupted") this.#emit({ type: "session.execution.interrupted", data })
|
|
526
|
+
else if (status === "failed") this.#emit({ type: "session.execution.failed", data: { ...data, ...(error !== undefined ? { error: { message: error } } : {}) } })
|
|
527
|
+
else this.#emit({ type: "session.execution.succeeded", data })
|
|
528
|
+
thread.items.clear()
|
|
529
|
+
const waiter = thread.waiters.get(turnID)
|
|
530
|
+
thread.waiters.delete(turnID)
|
|
531
|
+
waiter?.resolve(end)
|
|
532
|
+
const next = thread.queue.shift()
|
|
533
|
+
if (next) this.#startTurn(thread, [textInput(next.text)], "hub", next.inboxID).catch((cause) => this.#log(`queued hub message ${next.inboxID}: ${message(cause)}`))
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
#itemStarted(thread: Thread, params: Record<string, any>): void {
|
|
537
|
+
const item = params.item
|
|
538
|
+
if (!isObj(item) || typeof item.id !== "string") return
|
|
539
|
+
if (item.type === "userMessage") {
|
|
540
|
+
const clientID = str(item.clientId)
|
|
541
|
+
const origin = clientID ? thread.inputs.get(clientID) : undefined
|
|
542
|
+
if (clientID) thread.inputs.delete(clientID)
|
|
543
|
+
this.#emit({ type: "session.inbox.delivered", data: { sessionID: thread.hubID, inboxID: plainID(clientID && origin ? clientID : item.id), origin: origin ?? "user" } })
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
if (!TOOL_ITEMS.has(item.type)) return
|
|
547
|
+
thread.items.set(item.id, { type: item.type, paths: changedPaths(item) })
|
|
548
|
+
const summary = toolSummary(item)
|
|
549
|
+
const turnID = str(params.turnId)
|
|
550
|
+
this.#emit({
|
|
551
|
+
type: "session.tool.called",
|
|
552
|
+
data: { sessionID: thread.hubID, id: plainID(item.id), ...(turnID ? { assistantMessageID: plainID(turnID) } : {}), ...(summary !== undefined ? { summary } : {}) },
|
|
553
|
+
})
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
#itemCompleted(thread: Thread, params: Record<string, any>): void {
|
|
557
|
+
const item = params.item
|
|
558
|
+
if (!isObj(item) || typeof item.id !== "string") return
|
|
559
|
+
if (item.type === "agentMessage") {
|
|
560
|
+
if (typeof item.text === "string" && item.text !== "") thread.reply = { id: item.id, text: item.text }
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
if (!TOOL_ITEMS.has(item.type)) return
|
|
564
|
+
thread.items.delete(item.id)
|
|
565
|
+
const id = plainID(item.id)
|
|
566
|
+
if (item.status === "failed" || item.status === "declined") {
|
|
567
|
+
const error = item.status === "declined" ? "declined" : typeof item.exitCode === "number" ? `exit code ${item.exitCode}` : "failed"
|
|
568
|
+
this.#emit({ type: "session.tool.failed", data: { sessionID: thread.hubID, id, error } })
|
|
569
|
+
} else this.#emit({ type: "session.tool.success", data: { sessionID: thread.hubID, id } })
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
574
|
+
|
|
575
|
+
/** The app-server input for a prompt: its text (when any) and its images as data URLs. */
|
|
576
|
+
function input(text: string, images: readonly PromptImage[] | undefined): unknown[] {
|
|
577
|
+
return [...(text ? [textInput(text)] : []), ...(images ?? []).map((image) => ({ type: "image", url: `data:${image.mime};base64,${image.data}` }))]
|
|
578
|
+
}
|