@huynle/hub 0.7.4 → 0.8.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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6871 -3460
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +175 -6
- package/packages/protocol/src/launch.ts +36 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +776 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +350 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +86 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +69 -7
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +786 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +910 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
package/src/spoke/spoke.ts
CHANGED
|
@@ -33,19 +33,23 @@ import {
|
|
|
33
33
|
type TurnResult,
|
|
34
34
|
} from "../protocol"
|
|
35
35
|
import { extractReply } from "../reply"
|
|
36
|
+
import { busyError, NOT_A_USER_MESSAGE, REVERT_BUSY, revertPreview, revertSummary, userContent, userIndex, visibleMessages } from "./fork-revert"
|
|
36
37
|
import { TurnTracker } from "../turns"
|
|
37
38
|
import { TimelineCache, boundedTimelinePart, newestPage, timelineFile, timelineItems } from "./timeline"
|
|
38
39
|
import { SpokeConnection, type Backoff, type ConnectionState } from "./client"
|
|
39
40
|
import { PROMPT_SOURCE, VOICE_AGENT } from "./conversation"
|
|
40
|
-
import { DeliveryLedger, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery } from "./deliver"
|
|
41
|
+
import { DeliveryLedger, deferredWhileBusy, deliverMetadata, deliverText, harnessMessageID, InboxOrigins, planDelivery, QUIET_WHILE_BUSY } from "./deliver"
|
|
41
42
|
import { pollEnrollment, startEnrollment } from "./enroll"
|
|
42
43
|
import { HerdrWatcher, openInHerdr, type HerdrRunner } from "./herdr"
|
|
43
44
|
import type { LocalHubResult } from "./localhub"
|
|
44
45
|
import { launchDirectoryProblem } from "./node-roots"
|
|
45
46
|
import { PresenceBook } from "./presence"
|
|
47
|
+
import { OpenCodeProfiles } from "./profile-apply"
|
|
46
48
|
import { SessionTokenStore } from "./session-tokens"
|
|
49
|
+
import { SessionContexts } from "./session-context"
|
|
50
|
+
import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge.ts"
|
|
47
51
|
import { changeStats, contextUsage, filePatch, mcpServers } from "./summary"
|
|
48
|
-
import { browserSessions, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
|
|
52
|
+
import { browserSessions, eventSessionID, isExposed, isReported, SessionTracker, shouldForward, type EventLike, type SessionDetails } from "./tracker"
|
|
49
53
|
import { renderTaskBoard, TaskBoard } from "./taskboard"
|
|
50
54
|
import { watchConfig, type ConfigWatcher } from "./watch"
|
|
51
55
|
|
|
@@ -138,18 +142,36 @@ export interface SpokeHost {
|
|
|
138
142
|
*/
|
|
139
143
|
activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
|
|
140
144
|
/**
|
|
141
|
-
* Recent root sessions
|
|
142
|
-
*
|
|
145
|
+
* Recent root sessions with their workspace: those in the history window (`days`, at most `max`), plus `include`
|
|
146
|
+
* (always listed: open, running, ...). The background service lists every session; a foreground `opencode serve`
|
|
147
|
+
* only those of the locations it loaded. Absent (a TUI's own server, which this process cannot reach): the
|
|
148
|
+
* process reports only the sessions it sees active.
|
|
143
149
|
*/
|
|
144
|
-
|
|
150
|
+
/** `signal` aborts when the spoke abandons the listing because it timed out (listingTimeoutMs): stop asking the server. */
|
|
151
|
+
listRecent?(options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }): Promise<Array<{ id: string } & SessionDetails>>
|
|
145
152
|
/** Creates a session in a directory (service processes only). */
|
|
146
153
|
createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
|
|
147
154
|
/** MCP servers and their status for a directory (raw OpenCode McpServer list). */
|
|
148
155
|
mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
|
|
149
|
-
/** Working-tree changes of a directory (raw OpenCode FileDiffInfo list). */
|
|
150
|
-
vcsDiff?(directory: string): Promise<ReadonlyArray<unknown
|
|
156
|
+
/** Working-tree changes of a directory (raw OpenCode FileDiffInfo list); undefined when nothing can read that directory. */
|
|
157
|
+
vcsDiff?(directory: string): Promise<ReadonlyArray<unknown> | undefined>
|
|
151
158
|
/** Current branch of a directory. */
|
|
152
159
|
vcsBranch?(directory: string): Promise<string | undefined>
|
|
160
|
+
/** (HWA-16) A location's local skill and command names (they win over a hub profile's); absent: none applied. */
|
|
161
|
+
localNames?(directory: string): Promise<{ skills: readonly string[]; commands: readonly string[] }>
|
|
162
|
+
/** (HWA-16) The plugin registered the hub_profile tool (profile skills and commands need it). */
|
|
163
|
+
readonly profileTool?: boolean
|
|
164
|
+
/**
|
|
165
|
+
* Forks a session before one of its messages (OpenCode's session.fork over the own server; the plugin API has
|
|
166
|
+
* none). Absent: this process cannot reach its server, so it does not announce "fork".
|
|
167
|
+
*/
|
|
168
|
+
forkSession?(input: { sessionID: string; before: string }): Promise<{ id: string; title?: string; location?: { directory?: string } }>
|
|
169
|
+
/** Stages a revert from a message (session.revert.stage); with `files`, restores the files changed since. */
|
|
170
|
+
revertStage?(input: { sessionID: string; messageID: string; files: boolean }): Promise<unknown>
|
|
171
|
+
/** Undoes a staged revert (session.revert.clear). */
|
|
172
|
+
revertClear?(sessionID: string): Promise<unknown>
|
|
173
|
+
/** Makes a staged revert permanent (session.revert.commit). Absent with the two above: no "revert". */
|
|
174
|
+
revertCommit?(sessionID: string): Promise<unknown>
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
/** A form reply for OpenCode: an answer, or a cancel. */
|
|
@@ -164,6 +186,10 @@ const RELAY_TIMEOUT_MS = 15_000
|
|
|
164
186
|
const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from the hub; answer it in the terminal"
|
|
165
187
|
|
|
166
188
|
export interface SpokeOptions {
|
|
189
|
+
/** (CON-04) Where Concierge session locks persist (the plugin's state dir); memory only without it. */
|
|
190
|
+
readonly conciergeLockFile?: string
|
|
191
|
+
/** Other Concierge lock files read, never written (the other default state dir places; FX-08, fail closed). */
|
|
192
|
+
readonly conciergeLockReadFiles?: readonly string[]
|
|
167
193
|
readonly configFile: string
|
|
168
194
|
readonly machineName: string
|
|
169
195
|
readonly kind: ProcessKind
|
|
@@ -176,6 +202,8 @@ export interface SpokeOptions {
|
|
|
176
202
|
readonly seedIntervalMs?: number
|
|
177
203
|
/** Delay before re-listing the service's sessions after a session event (default 2 s). */
|
|
178
204
|
readonly seedDebounceMs?: number
|
|
205
|
+
/** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
|
|
206
|
+
readonly listingTimeoutMs?: number
|
|
179
207
|
readonly pollMs?: number
|
|
180
208
|
readonly backoff?: Backoff
|
|
181
209
|
/**
|
|
@@ -270,12 +298,16 @@ const FALLBACK_AGENT = "build"
|
|
|
270
298
|
const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
|
|
271
299
|
const ENROLL_RETRY_MS = 500
|
|
272
300
|
const NOT_SHARED = "This session is not shared with voice"
|
|
301
|
+
const CANNOT_FORK = "This OpenCode process cannot fork sessions (its server cannot be reached)"
|
|
302
|
+
const CANNOT_REVERT = "This OpenCode process cannot revert sessions (its server cannot be reached)"
|
|
303
|
+
/** OpenCode's revert events (session.revert.staged, .cleared, .committed). */
|
|
304
|
+
const REVERT_EVENTS = new Set(["session.revert.staged", "session.revert.cleared", "session.revert.committed"])
|
|
273
305
|
const CANNOT_AUTO_NAME = "This OpenCode process cannot auto-name sessions (its server cannot be reached); use /rename in its terminal"
|
|
274
306
|
export const AUTO_NAME_WAIT_MS = 20_000
|
|
275
307
|
/** Longest session title the hub sets (OpenCode's own titles are shorter). */
|
|
276
308
|
export const MAX_TITLE_CHARS = 200
|
|
277
309
|
const UNKNOWN_SESSION = "Unknown session"
|
|
278
|
-
/** Events after which
|
|
310
|
+
/** Events after which a listing process re-lists its sessions (other processes' sessions appear, change, go). */
|
|
279
311
|
const LISTING_EVENTS = new Set(["session.created", "session.renamed", "session.deleted"])
|
|
280
312
|
|
|
281
313
|
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
@@ -292,6 +324,10 @@ export class Spoke {
|
|
|
292
324
|
private connection?: SpokeConnection
|
|
293
325
|
private connectionKey = ""
|
|
294
326
|
private readonly tracker = new SessionTracker()
|
|
327
|
+
/** Hub profiles applied to sessions (./profile-apply): session-scoped layers in memory, read by the context hook. */
|
|
328
|
+
readonly profiles: OpenCodeProfiles
|
|
329
|
+
/** (CON-04) Hub session contexts (sessionContext op): per-turn parts, denied tools and lockAgent, read by the context hook. */
|
|
330
|
+
readonly contexts: SessionContexts
|
|
295
331
|
/** Session tokens from the hub (sessionTokens op), for the sessions' shells (P1-T6). Never logged. */
|
|
296
332
|
private readonly tokens = new SessionTokenStore()
|
|
297
333
|
/**
|
|
@@ -327,7 +363,18 @@ export class Spoke {
|
|
|
327
363
|
private conversationChain: Promise<unknown> = Promise.resolve()
|
|
328
364
|
private readonly abort = new AbortController()
|
|
329
365
|
private snapshotTimer?: ReturnType<typeof setTimeout>
|
|
366
|
+
/** Sessions the last snapshot sent listed (the hub keeps status only for sessions it was told about). */
|
|
367
|
+
private announced = new Set<string>()
|
|
368
|
+
/** Sessions whose run failed before any snapshot listed them: the failure goes out again once one does. */
|
|
369
|
+
private unheardFailures = new Set<string>()
|
|
330
370
|
private seedTimer?: ReturnType<typeof setTimeout>
|
|
371
|
+
/** A listing runs; `relist`: another was asked for meanwhile (runs once it ends, never alongside). */
|
|
372
|
+
private listing = false
|
|
373
|
+
private relist = false
|
|
374
|
+
/** Bumped per listing: a listing applies its result only while it is still the latest (a timed-out one never does). */
|
|
375
|
+
private listingGeneration = 0
|
|
376
|
+
/** A listing timed out and was logged; reset by the next listing that completes. */
|
|
377
|
+
private listingHung = false
|
|
331
378
|
private intervals: Array<ReturnType<typeof setInterval>> = []
|
|
332
379
|
private enrollment?: { state: EnrollmentState; abort: AbortController }
|
|
333
380
|
private started = false
|
|
@@ -366,6 +413,16 @@ export class Spoke {
|
|
|
366
413
|
) {
|
|
367
414
|
this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
|
|
368
415
|
this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
|
|
416
|
+
this.contexts = new SessionContexts({
|
|
417
|
+
...(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {}),
|
|
418
|
+
...(options.conciergeLockReadFiles?.length ? { readLockFiles: options.conciergeLockReadFiles } : {}),
|
|
419
|
+
})
|
|
420
|
+
const localNames = host.localNames?.bind(host)
|
|
421
|
+
this.profiles = new OpenCodeProfiles({
|
|
422
|
+
isBusy: (id) => this.running.has(id) || this.tracker.isBusy(id),
|
|
423
|
+
...(localNames ? { localNames: (id: string) => localNames(this.directoryOf(id)) } : {}),
|
|
424
|
+
tool: () => host.profileTool === true,
|
|
425
|
+
})
|
|
369
426
|
}
|
|
370
427
|
|
|
371
428
|
start(): void {
|
|
@@ -374,9 +431,9 @@ export class Spoke {
|
|
|
374
431
|
if (!this.options.launch) this.watch()
|
|
375
432
|
void this.listen()
|
|
376
433
|
this.apply(this.config)
|
|
377
|
-
this.every(this.options.snapshotIntervalMs ?? 30_000, () => this.sendSnapshot())
|
|
434
|
+
this.every(this.options.snapshotIntervalMs ?? 30_000, () => (void this.refreshLockedAgents(), this.sendSnapshot()))
|
|
378
435
|
this.every(this.options.presenceCheckMs ?? 5000, () => this.checkOpen())
|
|
379
|
-
if (this.
|
|
436
|
+
if (this.host.listRecent) {
|
|
380
437
|
void this.seed()
|
|
381
438
|
this.every(this.options.seedIntervalMs ?? 60_000, () => void this.seed())
|
|
382
439
|
}
|
|
@@ -502,7 +559,7 @@ export class Spoke {
|
|
|
502
559
|
if (key === this.openKey) return
|
|
503
560
|
this.openKey = key
|
|
504
561
|
const unknown = [...open.keys()].filter((id) => !this.tracker.has(id))
|
|
505
|
-
if (unknown.length && this.
|
|
562
|
+
if (unknown.length && this.host.listRecent) this.scheduleSeed()
|
|
506
563
|
else if (unknown.length) {
|
|
507
564
|
for (const id of unknown) this.tracker.ensure(id, Date.now())
|
|
508
565
|
void this.fetchInfo()
|
|
@@ -825,7 +882,13 @@ export class Spoke {
|
|
|
825
882
|
opencodeVersion: this.host.opencodeVersion,
|
|
826
883
|
pluginVersion: this.options.pluginVersion,
|
|
827
884
|
// Hub messages and prompts need OpenCode's synthetic messages.
|
|
828
|
-
|
|
885
|
+
// Fork and revert need OpenCode's own server (the plugin API has neither).
|
|
886
|
+
features: SPOKE_FEATURES.filter(
|
|
887
|
+
(f) =>
|
|
888
|
+
((f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined) &&
|
|
889
|
+
(f !== "fork" || this.host.forkSession !== undefined) &&
|
|
890
|
+
(f !== "revert" || this.canRevert()),
|
|
891
|
+
),
|
|
829
892
|
harness: HARNESS_OPENCODE,
|
|
830
893
|
harnessVersion: this.host.opencodeVersion,
|
|
831
894
|
evidence: "native",
|
|
@@ -905,6 +968,13 @@ export class Spoke {
|
|
|
905
968
|
private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
|
|
906
969
|
const entry = this.conversations.get(sessionID)
|
|
907
970
|
const session = await this.host.getSession(sessionID)
|
|
971
|
+
// (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
|
|
972
|
+
// never escapes its restricted agent, and the context hook adds no orchestrator parts).
|
|
973
|
+
// A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
|
|
974
|
+
if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
|
|
975
|
+
this.conversations.set(sessionID, {})
|
|
976
|
+
return { agent: session.agent ?? (await this.defaultAgent()) }
|
|
977
|
+
}
|
|
908
978
|
this.rememberModel(sessionID, session.model)
|
|
909
979
|
const current = session.agent
|
|
910
980
|
const models = await this.voiceModelOn(sessionID, session, entry)
|
|
@@ -1023,9 +1093,10 @@ export class Spoke {
|
|
|
1023
1093
|
return this.modelPrefsResult(req.sessionID)
|
|
1024
1094
|
}
|
|
1025
1095
|
case "timeline": {
|
|
1026
|
-
// A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly.
|
|
1096
|
+
// A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
|
|
1097
|
+
// staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
|
|
1027
1098
|
const first = req.cursor === undefined && req.before === undefined
|
|
1028
|
-
const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.
|
|
1099
|
+
const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.shownMessages(req.sessionID)), first)
|
|
1029
1100
|
if (req.before !== undefined || req.order === "desc") return newestPage(items, req.limit ?? 50, req.before) satisfies ResultFor["timeline"]
|
|
1030
1101
|
const offset = req.cursor === undefined ? 0 : Number(req.cursor)
|
|
1031
1102
|
if (!Number.isInteger(offset) || offset < 0) throw new Error("Invalid timeline cursor")
|
|
@@ -1069,6 +1140,134 @@ export class Spoke {
|
|
|
1069
1140
|
return this.setConversation(req.sessionID, req.on)
|
|
1070
1141
|
case "deliver":
|
|
1071
1142
|
return this.deliver(req)
|
|
1143
|
+
case "sessionContext": {
|
|
1144
|
+
// Only a session this process reports (a context needs a real session).
|
|
1145
|
+
if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
|
|
1146
|
+
const result = this.contexts.handle(req)
|
|
1147
|
+
// The hub sees the new revision (and the session's agent) in the next sessions frame.
|
|
1148
|
+
void this.refreshLockedAgents().finally(() => this.scheduleSnapshot())
|
|
1149
|
+
return result
|
|
1150
|
+
}
|
|
1151
|
+
case "profileApply":
|
|
1152
|
+
// Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
|
|
1153
|
+
if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
|
|
1154
|
+
return this.profiles.handle(req)
|
|
1155
|
+
case "fork":
|
|
1156
|
+
return this.fork(req.sessionID, req.messageID)
|
|
1157
|
+
case "revertPreview": {
|
|
1158
|
+
const messages = await this.host.context(req.sessionID)
|
|
1159
|
+
const index = userIndex(messages, req.messageID)
|
|
1160
|
+
if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
|
|
1161
|
+
return revertPreview(messages, index) satisfies ResultFor["revertPreview"]
|
|
1162
|
+
}
|
|
1163
|
+
case "revert":
|
|
1164
|
+
return this.revert(req.sessionID, req.messageID, req.files)
|
|
1165
|
+
case "revertRestore":
|
|
1166
|
+
if (!this.canRevert()) throw new Error(CANNOT_REVERT)
|
|
1167
|
+
await this.host.revertClear!(req.sessionID)
|
|
1168
|
+
return this.revertDone(req.sessionID)
|
|
1169
|
+
case "revertCommit":
|
|
1170
|
+
if (!this.canRevert()) throw new Error(CANNOT_REVERT)
|
|
1171
|
+
await this.host.revertCommit!(req.sessionID)
|
|
1172
|
+
return this.revertDone(req.sessionID)
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
private canRevert(): boolean {
|
|
1177
|
+
return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Forks the session before one of its user messages (feature "fork") and reports the new session at once (the
|
|
1182
|
+
* snapshot goes out before the reply). The result carries the message's text and attachments for the composer.
|
|
1183
|
+
*/
|
|
1184
|
+
private async fork(sessionID: string, messageID: string): Promise<ResultFor["fork"]> {
|
|
1185
|
+
const fork = this.host.forkSession?.bind(this.host)
|
|
1186
|
+
if (!fork) throw new Error(CANNOT_FORK)
|
|
1187
|
+
const messages = await this.host.context(sessionID)
|
|
1188
|
+
const index = userIndex(messages, messageID)
|
|
1189
|
+
if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
|
|
1190
|
+
const session = await fork({ sessionID, before: messageID })
|
|
1191
|
+
if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
|
|
1192
|
+
const title = typeof session.title === "string" ? session.title : ""
|
|
1193
|
+
const directory = typeof session.location?.directory === "string" && session.location.directory ? session.location.directory : (this.tracker.directoryOf(sessionID) ?? this.host.directory)
|
|
1194
|
+
const now = Date.now()
|
|
1195
|
+
const source = this.tracker.snapshot(now).find((s) => s.id === sessionID)
|
|
1196
|
+
this.tracker.observe({ type: "session.created", data: { sessionID: session.id, title, location: { directory } } }, now)
|
|
1197
|
+
this.tracker.setInfo(session.id, {
|
|
1198
|
+
title,
|
|
1199
|
+
directory,
|
|
1200
|
+
updated: now,
|
|
1201
|
+
...(source?.project ? { project: source.project } : {}),
|
|
1202
|
+
...(source?.projectName ? { projectName: source.projectName } : {}),
|
|
1203
|
+
})
|
|
1204
|
+
if (this.config.mode === "session") this.setSession(session.id, true)
|
|
1205
|
+
this.sendSnapshot()
|
|
1206
|
+
return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
|
|
1211
|
+
* `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
|
|
1212
|
+
*/
|
|
1213
|
+
private async revert(sessionID: string, messageID: string, files: boolean): Promise<ResultFor["revert"]> {
|
|
1214
|
+
if (!this.canRevert()) throw new Error(CANNOT_REVERT)
|
|
1215
|
+
const messages = await this.host.context(sessionID)
|
|
1216
|
+
const index = userIndex(messages, messageID)
|
|
1217
|
+
if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
|
|
1218
|
+
if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new Error(REVERT_BUSY)
|
|
1219
|
+
let staged: unknown
|
|
1220
|
+
try {
|
|
1221
|
+
staged = await this.host.revertStage!({ sessionID, messageID, files })
|
|
1222
|
+
} catch (cause) {
|
|
1223
|
+
if (busyError(cause)) throw new Error(REVERT_BUSY)
|
|
1224
|
+
throw cause
|
|
1225
|
+
}
|
|
1226
|
+
const revert = revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!
|
|
1227
|
+
this.timelines.invalidate(sessionID)
|
|
1228
|
+
// Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
|
|
1229
|
+
this.tracker.setRevert(sessionID, revert)
|
|
1230
|
+
this.sendSnapshot()
|
|
1231
|
+
return { revert, ...userContent(messages[index]) }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/** After a restore or commit: no revert is staged; reported at once. */
|
|
1235
|
+
private revertDone(sessionID: string): Record<string, never> {
|
|
1236
|
+
this.timelines.invalidate(sessionID)
|
|
1237
|
+
this.tracker.setRevert(sessionID, undefined)
|
|
1238
|
+
this.sendSnapshot()
|
|
1239
|
+
return {}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/**
|
|
1243
|
+
* The session's messages a timeline shows: all of them, or with a staged revert those before its message. The
|
|
1244
|
+
* revert is read from OpenCode's session (so one staged before this process saw it counts) and reported.
|
|
1245
|
+
*/
|
|
1246
|
+
private async shownMessages(sessionID: string): Promise<readonly unknown[]> {
|
|
1247
|
+
const [messages, session] = await Promise.all([
|
|
1248
|
+
this.host.context(sessionID),
|
|
1249
|
+
this.canRevert() ? this.host.getSession(sessionID).catch(() => undefined) : Promise.resolve(undefined),
|
|
1250
|
+
])
|
|
1251
|
+
if (!this.canRevert()) return messages
|
|
1252
|
+
const raw = session ? (session as { revert?: unknown }).revert : undefined
|
|
1253
|
+
const revert = session ? revertSummary(raw, messages) : this.tracker.revertOf(sessionID)
|
|
1254
|
+
if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
|
|
1255
|
+
return visibleMessages(messages, revert?.messageID)
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. */
|
|
1259
|
+
private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
|
|
1260
|
+
if (!this.tracker.has(sessionID)) return
|
|
1261
|
+
if (event.type !== "session.revert.staged") {
|
|
1262
|
+
if (this.tracker.setRevert(sessionID, undefined)) this.scheduleSnapshot()
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
try {
|
|
1266
|
+
const raw = (event.data as Record<string, unknown>).revert
|
|
1267
|
+
const revert = revertSummary(raw, await this.host.context(sessionID))
|
|
1268
|
+
if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
|
|
1269
|
+
} catch (cause) {
|
|
1270
|
+
console.error("[hub] could not read a staged revert", cause)
|
|
1072
1271
|
}
|
|
1073
1272
|
}
|
|
1074
1273
|
|
|
@@ -1081,11 +1280,11 @@ export class Spoke {
|
|
|
1081
1280
|
if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
|
|
1082
1281
|
return this.deliveries.run(req.messageID, req.wake, async () => {
|
|
1083
1282
|
const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
})
|
|
1283
|
+
const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
|
|
1284
|
+
// A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
|
|
1285
|
+
// pending and sends it again after the turn (the turn's end sends a sessions frame). Nothing is admitted.
|
|
1286
|
+
if (busy && deferredWhileBusy(req, subagent)) throw new Error(QUIET_WHILE_BUSY)
|
|
1287
|
+
const plan = planDelivery(req, { busy, formOpen: this.tracker.formOpenIn(req.sessionID), subagent })
|
|
1089
1288
|
const id = harnessMessageID(req.messageID)
|
|
1090
1289
|
// Marked before sending: OpenCode may report it delivered before the call returns.
|
|
1091
1290
|
this.origins.hub(id)
|
|
@@ -1488,6 +1687,7 @@ export class Spoke {
|
|
|
1488
1687
|
if (event.type === "session.deleted" && typeof sessionID === "string") {
|
|
1489
1688
|
this.tokens.delete(sessionID)
|
|
1490
1689
|
this.actTokens.delete(sessionID)
|
|
1690
|
+
this.profiles.forget(sessionID)
|
|
1491
1691
|
}
|
|
1492
1692
|
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
1493
1693
|
const formID = (event.data as Record<string, unknown> | undefined)?.id
|
|
@@ -1502,7 +1702,9 @@ export class Spoke {
|
|
|
1502
1702
|
if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
|
|
1503
1703
|
}
|
|
1504
1704
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
1505
|
-
if (
|
|
1705
|
+
if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
|
|
1706
|
+
if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
|
|
1707
|
+
if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
|
|
1506
1708
|
this.scheduleSeed()
|
|
1507
1709
|
}
|
|
1508
1710
|
void this.fetchInfo()
|
|
@@ -1510,7 +1712,11 @@ export class Spoke {
|
|
|
1510
1712
|
// Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
|
|
1511
1713
|
// get their session from the job.
|
|
1512
1714
|
const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
|
|
1513
|
-
if (compact && shouldForward(this.config, this.tracker, compact))
|
|
1715
|
+
if (compact && shouldForward(this.config, this.tracker, compact)) {
|
|
1716
|
+
this.announce(compact)
|
|
1717
|
+
this.connection.send({ t: "event", event: compact })
|
|
1718
|
+
if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
|
|
1719
|
+
}
|
|
1514
1720
|
}
|
|
1515
1721
|
} catch (cause) {
|
|
1516
1722
|
console.error("[hub] event handling failed", cause)
|
|
@@ -1599,13 +1805,52 @@ export class Spoke {
|
|
|
1599
1805
|
}
|
|
1600
1806
|
|
|
1601
1807
|
private async seed(): Promise<void> {
|
|
1808
|
+
if (this.listing) {
|
|
1809
|
+
this.relist = true
|
|
1810
|
+
return
|
|
1811
|
+
}
|
|
1812
|
+
this.listing = true
|
|
1813
|
+
try {
|
|
1814
|
+
do {
|
|
1815
|
+
this.relist = false
|
|
1816
|
+
await this.listOnce()
|
|
1817
|
+
} while (this.relist && this.started)
|
|
1818
|
+
} finally {
|
|
1819
|
+
this.listing = false
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* One listing, bounded by listingTimeoutMs: a server that accepts and never answers must not stop later listings
|
|
1825
|
+
* (seed() runs one at a time). A timed-out listing is abandoned (logged once until a listing completes again);
|
|
1826
|
+
* if it settles later its result is dropped, so it never overwrites a newer listing.
|
|
1827
|
+
*/
|
|
1828
|
+
private async listOnce(): Promise<void> {
|
|
1829
|
+
const generation = ++this.listingGeneration
|
|
1830
|
+
const limit = this.options.listingTimeoutMs ?? LISTING_TIMEOUT_MS
|
|
1831
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1832
|
+
const timedOut = Symbol("timeout")
|
|
1833
|
+
const cancel = new AbortController()
|
|
1602
1834
|
try {
|
|
1603
1835
|
const keep = this.keep()
|
|
1604
1836
|
const { days, max } = this.config.list
|
|
1605
|
-
const
|
|
1837
|
+
const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep], signal: cancel.signal }))
|
|
1838
|
+
listed.catch(() => {}) // an abandoned listing that fails later is not an unhandled rejection
|
|
1839
|
+
const result = await Promise.race([listed, new Promise<typeof timedOut>((resolve) => (timer = setTimeout(() => resolve(timedOut), limit)))])
|
|
1840
|
+
if (result === timedOut) {
|
|
1841
|
+
cancel.abort(new Error(`listing timed out after ${limit} ms`))
|
|
1842
|
+
if (!this.listingHung) console.error(`[hub] listing recent sessions timed out after ${limit} ms; listing again later`)
|
|
1843
|
+
this.listingHung = true
|
|
1844
|
+
return
|
|
1845
|
+
}
|
|
1846
|
+
this.listingHung = false
|
|
1847
|
+
if (generation !== this.listingGeneration) return
|
|
1848
|
+
const sessions = result ?? []
|
|
1606
1849
|
if (this.tracker.applyListing(sessions, Date.now(), keep)) this.scheduleSnapshot()
|
|
1607
1850
|
} catch (cause) {
|
|
1608
1851
|
console.error("[hub] could not list recent sessions", errorText(cause))
|
|
1852
|
+
} finally {
|
|
1853
|
+
clearTimeout(timer)
|
|
1609
1854
|
}
|
|
1610
1855
|
}
|
|
1611
1856
|
|
|
@@ -1625,9 +1870,68 @@ export class Spoke {
|
|
|
1625
1870
|
}, this.options.snapshotDebounceMs ?? 500)
|
|
1626
1871
|
}
|
|
1627
1872
|
|
|
1873
|
+
/**
|
|
1874
|
+
* (CON-04) The agent a locked (Concierge) session runs on, as the context hook or OpenCode reports it: a change
|
|
1875
|
+
* goes to the hub in the next sessions frame (the hub suspends a Concierge on another agent).
|
|
1876
|
+
*/
|
|
1877
|
+
noteAgent(sessionID: string, agent: string | undefined): void {
|
|
1878
|
+
if (this.contexts.seenAgent(sessionID, agent)) this.scheduleSnapshot()
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
/** Reads the current agent of every locked session this process reports (OpenCode's session record). */
|
|
1882
|
+
private async refreshLockedAgents(): Promise<void> {
|
|
1883
|
+
for (const sessionID of this.contexts.lockedSessions()) {
|
|
1884
|
+
if (!this.tracker.has(sessionID)) continue
|
|
1885
|
+
try {
|
|
1886
|
+
this.noteAgent(sessionID, (await this.host.getSession(sessionID)).agent)
|
|
1887
|
+
} catch {
|
|
1888
|
+
// Gone or unreachable: the next refresh tries again.
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1628
1893
|
private sendSnapshot(): void {
|
|
1894
|
+
if (this.snapshotTimer) clearTimeout(this.snapshotTimer)
|
|
1895
|
+
this.snapshotTimer = undefined
|
|
1629
1896
|
const tui = this.presenceBook.open(Date.now())
|
|
1630
|
-
|
|
1897
|
+
const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) }).map((s) => ({ ...s, ...this.contexts.report(s.id) }))
|
|
1898
|
+
this.connection?.send({ t: "sessions", sessions })
|
|
1899
|
+
this.announced = new Set(sessions.map((s) => s.id))
|
|
1900
|
+
this.resendUnheardFailures()
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
/**
|
|
1904
|
+
* A run that failed while its session's details were still being fetched went out before any snapshot listed the
|
|
1905
|
+
* session, so the hub dropped it: once a snapshot lists the session, its failure goes out again (unless a run
|
|
1906
|
+
* started since).
|
|
1907
|
+
*/
|
|
1908
|
+
private resendUnheardFailures(): void {
|
|
1909
|
+
for (const id of [...this.unheardFailures]) {
|
|
1910
|
+
const error = this.tracker.errorOf(id)
|
|
1911
|
+
if (!error || !this.tracker.has(id)) {
|
|
1912
|
+
this.unheardFailures.delete(id)
|
|
1913
|
+
continue
|
|
1914
|
+
}
|
|
1915
|
+
if (!this.announced.has(id) || this.connection?.state !== "connected") continue
|
|
1916
|
+
this.unheardFailures.delete(id)
|
|
1917
|
+
const event = { type: "session.execution.failed", data: { sessionID: id, error } }
|
|
1918
|
+
if (shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Sends the snapshot now when an event is about a session the hub was not told about yet: the hub drops a run's
|
|
1924
|
+
* events (a failure, a permission) for sessions it does not know, and a run can fail within milliseconds of
|
|
1925
|
+
* session.created (a model that does not exist), well before the debounced snapshot. Creating, renaming or
|
|
1926
|
+
* deleting a session alone waits for the debounce (a burst of subagents sends one snapshot).
|
|
1927
|
+
*/
|
|
1928
|
+
private announce(event: { type: string; data: Record<string, unknown> }): void {
|
|
1929
|
+
if (event.type === "session.created" || event.type === "session.renamed" || event.type === "session.deleted") return
|
|
1930
|
+
const sessionID = eventSessionID(event)
|
|
1931
|
+
if (!sessionID || this.announced.has(sessionID) || !this.tracker.hasInfo(sessionID)) return
|
|
1932
|
+
this.sendSnapshot()
|
|
1933
|
+
// Once per session even when the snapshot's limits leave it out.
|
|
1934
|
+
this.announced.add(sessionID)
|
|
1631
1935
|
}
|
|
1632
1936
|
|
|
1633
1937
|
private every(ms: number, run: () => void): void {
|
|
@@ -1637,6 +1941,9 @@ export class Spoke {
|
|
|
1637
1941
|
}
|
|
1638
1942
|
}
|
|
1639
1943
|
|
|
1944
|
+
/** Longest a session listing may take (SpokeOptions.listingTimeoutMs). */
|
|
1945
|
+
const LISTING_TIMEOUT_MS = 60_000
|
|
1946
|
+
|
|
1640
1947
|
/** Directories whose shells are listed when seeding. */
|
|
1641
1948
|
const SEED_DIRECTORIES = 20
|
|
1642
1949
|
|
package/src/spoke/timeline.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
|
|
1
|
+
import { DELIVER_ID_MAX, MESSAGE_KINDS, PROMPT_IMAGE_MIMES, summarizeToolInput, type MessageKind, type PromptImage, type TimelineMessageItem } from "../protocol"
|
|
2
2
|
import { PROMPT_SOURCE } from "./conversation"
|
|
3
3
|
|
|
4
4
|
/** An attachment of a user message: its position in the message's files (for timelineFile), type and name. */
|
|
@@ -32,6 +32,8 @@ export interface TimelineItem {
|
|
|
32
32
|
readonly callID?: string
|
|
33
33
|
/** Subagent (subagent/task) tool rows: the child session it runs in. */
|
|
34
34
|
readonly childSessionID?: string
|
|
35
|
+
/** (CON-09b) Tool rows: a short summary of the input (summarizeToolInput). */
|
|
36
|
+
readonly summary?: string
|
|
35
37
|
/** Subagent tool rows: the task's short description. */
|
|
36
38
|
readonly title?: string
|
|
37
39
|
/** Assistant text rows: which text part of the message this is (0, 1, …), as in live text deltas. */
|
|
@@ -100,10 +102,13 @@ export function timelineItems(sessionID: string, messages: readonly unknown[]):
|
|
|
100
102
|
const status = part.state?.status
|
|
101
103
|
const name = String(part.name ?? "tool")
|
|
102
104
|
const spawn = SUBAGENT_TOOLS.has(name) ? subagentOf(part.state) : {}
|
|
105
|
+
// (CON-09b) The same short summary live activity shows (the command, the path, the hub verb).
|
|
106
|
+
const summary = summarizeToolInput(part.state?.input)
|
|
103
107
|
out.push({
|
|
104
108
|
...base,
|
|
105
109
|
kind: "tool",
|
|
106
110
|
name,
|
|
111
|
+
...(summary ? { summary } : {}),
|
|
107
112
|
state: status === "error" ? "failed" : status === "completed" ? "done" : "running",
|
|
108
113
|
...(typeof part.id === "string" && part.id ? { callID: part.id } : {}),
|
|
109
114
|
...spawn,
|
package/src/spoke/tracker.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Sessions this OpenCode process knows about, built from the events it sees, plus which of them
|
|
2
2
|
// are exposed to the central server (by mode). Pure: time is passed in.
|
|
3
3
|
import type { Mode } from "../config"
|
|
4
|
-
import { compactActivity, compactRunError, FORWARDED_EVENTS, type CompactRunError, type CompactSessionStatus, type JobInfo, type SessionInfo, type TerminalInfo } from "../protocol"
|
|
4
|
+
import { compactActivity, compactRunError, FORWARDED_EVENTS, type CompactRunError, type CompactSessionStatus, type JobInfo, type SessionInfo, type SessionRevertInfo, type TerminalInfo } from "../protocol"
|
|
5
5
|
|
|
6
6
|
/** A session is "owned" by this process while it viewed or ran it within this window. */
|
|
7
7
|
export const OWNED_WINDOW_MS = 30 * 60 * 1000
|
|
@@ -35,6 +35,8 @@ interface Entry {
|
|
|
35
35
|
projectName?: string
|
|
36
36
|
/** Came from the service's listing of recent sessions (dropped when it leaves the listing). */
|
|
37
37
|
listed?: boolean
|
|
38
|
+
/** A staged revert (feature "revert"). */
|
|
39
|
+
revert?: SessionRevertInfo
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
const ACTIVITY = new Set([
|
|
@@ -183,6 +185,20 @@ export class SessionTracker {
|
|
|
183
185
|
return changed
|
|
184
186
|
}
|
|
185
187
|
|
|
188
|
+
/** The session's staged revert, if any. */
|
|
189
|
+
revertOf(id: string): SessionRevertInfo | undefined {
|
|
190
|
+
return this.entries.get(id)?.revert
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Sets (or, undefined, clears) a known session's staged revert. Returns true when it changed. */
|
|
194
|
+
setRevert(id: string, revert: SessionRevertInfo | undefined): boolean {
|
|
195
|
+
const entry = this.entries.get(id)
|
|
196
|
+
if (!entry || JSON.stringify(entry.revert ?? null) === JSON.stringify(revert ?? null)) return false
|
|
197
|
+
if (revert) entry.revert = revert
|
|
198
|
+
else delete entry.revert
|
|
199
|
+
return true
|
|
200
|
+
}
|
|
201
|
+
|
|
186
202
|
/** Fills in details fetched for a session that was first seen through an activity event. */
|
|
187
203
|
setInfo(id: string, details: SessionDetails): boolean {
|
|
188
204
|
const entry = this.entries.get(id)
|
|
@@ -331,6 +347,11 @@ export class SessionTracker {
|
|
|
331
347
|
.sort((a, b) => this.lineage(b).length - this.lineage(a).length)
|
|
332
348
|
}
|
|
333
349
|
|
|
350
|
+
/** The session is known with its details, so a snapshot lists it. */
|
|
351
|
+
hasInfo(id: string): boolean {
|
|
352
|
+
return this.entries.get(id)?.info === true
|
|
353
|
+
}
|
|
354
|
+
|
|
334
355
|
needsInfo(): string[] {
|
|
335
356
|
return [...this.entries.values()].filter((e) => !e.info).map((e) => e.id)
|
|
336
357
|
}
|
|
@@ -383,6 +404,7 @@ export class SessionTracker {
|
|
|
383
404
|
owned: e.lastActivity !== undefined && now - e.lastActivity <= OWNED_WINDOW_MS,
|
|
384
405
|
...(e.project ? { project: e.project } : {}),
|
|
385
406
|
...(e.projectName ? { projectName: e.projectName } : {}),
|
|
407
|
+
...(e.revert ? { revert: e.revert } : {}),
|
|
386
408
|
}))
|
|
387
409
|
}
|
|
388
410
|
|