@huynle/hub 0.7.3 → 0.8.0
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 +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- 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/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- 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/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- 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 +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -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 +169 -6
- package/packages/protocol/src/launch.ts +22 -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 +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- 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 +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- 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 +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- 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 +773 -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 +339 -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 +89 -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 +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- 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 +43 -6
- 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 +772 -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 +888 -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 +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- 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-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -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,8 +142,10 @@ 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
|
listRecent?(options?: { days: number; max: number; include: readonly string[] }): Promise<Array<{ id: string } & SessionDetails>>
|
|
145
151
|
/** Creates a session in a directory (service processes only). */
|
|
@@ -150,6 +156,21 @@ export interface SpokeHost {
|
|
|
150
156
|
vcsDiff?(directory: string): Promise<ReadonlyArray<unknown>>
|
|
151
157
|
/** Current branch of a directory. */
|
|
152
158
|
vcsBranch?(directory: string): Promise<string | undefined>
|
|
159
|
+
/** (HWA-16) A location's local skill and command names (they win over a hub profile's); absent: none applied. */
|
|
160
|
+
localNames?(directory: string): Promise<{ skills: readonly string[]; commands: readonly string[] }>
|
|
161
|
+
/** (HWA-16) The plugin registered the hub_profile tool (profile skills and commands need it). */
|
|
162
|
+
readonly profileTool?: boolean
|
|
163
|
+
/**
|
|
164
|
+
* Forks a session before one of its messages (OpenCode's session.fork over the own server; the plugin API has
|
|
165
|
+
* none). Absent: this process cannot reach its server, so it does not announce "fork".
|
|
166
|
+
*/
|
|
167
|
+
forkSession?(input: { sessionID: string; before: string }): Promise<{ id: string; title?: string; location?: { directory?: string } }>
|
|
168
|
+
/** Stages a revert from a message (session.revert.stage); with `files`, restores the files changed since. */
|
|
169
|
+
revertStage?(input: { sessionID: string; messageID: string; files: boolean }): Promise<unknown>
|
|
170
|
+
/** Undoes a staged revert (session.revert.clear). */
|
|
171
|
+
revertClear?(sessionID: string): Promise<unknown>
|
|
172
|
+
/** Makes a staged revert permanent (session.revert.commit). Absent with the two above: no "revert". */
|
|
173
|
+
revertCommit?(sessionID: string): Promise<unknown>
|
|
153
174
|
}
|
|
154
175
|
|
|
155
176
|
/** A form reply for OpenCode: an answer, or a cancel. */
|
|
@@ -164,6 +185,8 @@ const RELAY_TIMEOUT_MS = 15_000
|
|
|
164
185
|
const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from the hub; answer it in the terminal"
|
|
165
186
|
|
|
166
187
|
export interface SpokeOptions {
|
|
188
|
+
/** (CON-04) Where Concierge session locks persist (the plugin's state dir); memory only without it. */
|
|
189
|
+
readonly conciergeLockFile?: string
|
|
167
190
|
readonly configFile: string
|
|
168
191
|
readonly machineName: string
|
|
169
192
|
readonly kind: ProcessKind
|
|
@@ -176,6 +199,8 @@ export interface SpokeOptions {
|
|
|
176
199
|
readonly seedIntervalMs?: number
|
|
177
200
|
/** Delay before re-listing the service's sessions after a session event (default 2 s). */
|
|
178
201
|
readonly seedDebounceMs?: number
|
|
202
|
+
/** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
|
|
203
|
+
readonly listingTimeoutMs?: number
|
|
179
204
|
readonly pollMs?: number
|
|
180
205
|
readonly backoff?: Backoff
|
|
181
206
|
/**
|
|
@@ -270,12 +295,16 @@ const FALLBACK_AGENT = "build"
|
|
|
270
295
|
const LOCAL_HUB_MAX_RETRY_MS = 15 * 60_000
|
|
271
296
|
const ENROLL_RETRY_MS = 500
|
|
272
297
|
const NOT_SHARED = "This session is not shared with voice"
|
|
298
|
+
const CANNOT_FORK = "This OpenCode process cannot fork sessions (its server cannot be reached)"
|
|
299
|
+
const CANNOT_REVERT = "This OpenCode process cannot revert sessions (its server cannot be reached)"
|
|
300
|
+
/** OpenCode's revert events (session.revert.staged, .cleared, .committed). */
|
|
301
|
+
const REVERT_EVENTS = new Set(["session.revert.staged", "session.revert.cleared", "session.revert.committed"])
|
|
273
302
|
const CANNOT_AUTO_NAME = "This OpenCode process cannot auto-name sessions (its server cannot be reached); use /rename in its terminal"
|
|
274
303
|
export const AUTO_NAME_WAIT_MS = 20_000
|
|
275
304
|
/** Longest session title the hub sets (OpenCode's own titles are shorter). */
|
|
276
305
|
export const MAX_TITLE_CHARS = 200
|
|
277
306
|
const UNKNOWN_SESSION = "Unknown session"
|
|
278
|
-
/** Events after which
|
|
307
|
+
/** Events after which a listing process re-lists its sessions (other processes' sessions appear, change, go). */
|
|
279
308
|
const LISTING_EVENTS = new Set(["session.created", "session.renamed", "session.deleted"])
|
|
280
309
|
|
|
281
310
|
const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
|
|
@@ -292,6 +321,10 @@ export class Spoke {
|
|
|
292
321
|
private connection?: SpokeConnection
|
|
293
322
|
private connectionKey = ""
|
|
294
323
|
private readonly tracker = new SessionTracker()
|
|
324
|
+
/** Hub profiles applied to sessions (./profile-apply): session-scoped layers in memory, read by the context hook. */
|
|
325
|
+
readonly profiles: OpenCodeProfiles
|
|
326
|
+
/** (CON-04) Hub session contexts (sessionContext op): per-turn parts, denied tools and lockAgent, read by the context hook. */
|
|
327
|
+
readonly contexts: SessionContexts
|
|
295
328
|
/** Session tokens from the hub (sessionTokens op), for the sessions' shells (P1-T6). Never logged. */
|
|
296
329
|
private readonly tokens = new SessionTokenStore()
|
|
297
330
|
/**
|
|
@@ -327,7 +360,18 @@ export class Spoke {
|
|
|
327
360
|
private conversationChain: Promise<unknown> = Promise.resolve()
|
|
328
361
|
private readonly abort = new AbortController()
|
|
329
362
|
private snapshotTimer?: ReturnType<typeof setTimeout>
|
|
363
|
+
/** Sessions the last snapshot sent listed (the hub keeps status only for sessions it was told about). */
|
|
364
|
+
private announced = new Set<string>()
|
|
365
|
+
/** Sessions whose run failed before any snapshot listed them: the failure goes out again once one does. */
|
|
366
|
+
private unheardFailures = new Set<string>()
|
|
330
367
|
private seedTimer?: ReturnType<typeof setTimeout>
|
|
368
|
+
/** A listing runs; `relist`: another was asked for meanwhile (runs once it ends, never alongside). */
|
|
369
|
+
private listing = false
|
|
370
|
+
private relist = false
|
|
371
|
+
/** Bumped per listing: a listing applies its result only while it is still the latest (a timed-out one never does). */
|
|
372
|
+
private listingGeneration = 0
|
|
373
|
+
/** A listing timed out and was logged; reset by the next listing that completes. */
|
|
374
|
+
private listingHung = false
|
|
331
375
|
private intervals: Array<ReturnType<typeof setInterval>> = []
|
|
332
376
|
private enrollment?: { state: EnrollmentState; abort: AbortController }
|
|
333
377
|
private started = false
|
|
@@ -366,6 +410,13 @@ export class Spoke {
|
|
|
366
410
|
) {
|
|
367
411
|
this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
|
|
368
412
|
this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
|
|
413
|
+
this.contexts = new SessionContexts(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {})
|
|
414
|
+
const localNames = host.localNames?.bind(host)
|
|
415
|
+
this.profiles = new OpenCodeProfiles({
|
|
416
|
+
isBusy: (id) => this.running.has(id) || this.tracker.isBusy(id),
|
|
417
|
+
...(localNames ? { localNames: (id: string) => localNames(this.directoryOf(id)) } : {}),
|
|
418
|
+
tool: () => host.profileTool === true,
|
|
419
|
+
})
|
|
369
420
|
}
|
|
370
421
|
|
|
371
422
|
start(): void {
|
|
@@ -374,9 +425,9 @@ export class Spoke {
|
|
|
374
425
|
if (!this.options.launch) this.watch()
|
|
375
426
|
void this.listen()
|
|
376
427
|
this.apply(this.config)
|
|
377
|
-
this.every(this.options.snapshotIntervalMs ?? 30_000, () => this.sendSnapshot())
|
|
428
|
+
this.every(this.options.snapshotIntervalMs ?? 30_000, () => (void this.refreshLockedAgents(), this.sendSnapshot()))
|
|
378
429
|
this.every(this.options.presenceCheckMs ?? 5000, () => this.checkOpen())
|
|
379
|
-
if (this.
|
|
430
|
+
if (this.host.listRecent) {
|
|
380
431
|
void this.seed()
|
|
381
432
|
this.every(this.options.seedIntervalMs ?? 60_000, () => void this.seed())
|
|
382
433
|
}
|
|
@@ -502,7 +553,7 @@ export class Spoke {
|
|
|
502
553
|
if (key === this.openKey) return
|
|
503
554
|
this.openKey = key
|
|
504
555
|
const unknown = [...open.keys()].filter((id) => !this.tracker.has(id))
|
|
505
|
-
if (unknown.length && this.
|
|
556
|
+
if (unknown.length && this.host.listRecent) this.scheduleSeed()
|
|
506
557
|
else if (unknown.length) {
|
|
507
558
|
for (const id of unknown) this.tracker.ensure(id, Date.now())
|
|
508
559
|
void this.fetchInfo()
|
|
@@ -825,7 +876,13 @@ export class Spoke {
|
|
|
825
876
|
opencodeVersion: this.host.opencodeVersion,
|
|
826
877
|
pluginVersion: this.options.pluginVersion,
|
|
827
878
|
// Hub messages and prompts need OpenCode's synthetic messages.
|
|
828
|
-
|
|
879
|
+
// Fork and revert need OpenCode's own server (the plugin API has neither).
|
|
880
|
+
features: SPOKE_FEATURES.filter(
|
|
881
|
+
(f) =>
|
|
882
|
+
((f !== "deliver" && f !== "hubPrompt") || this.host.synthetic !== undefined) &&
|
|
883
|
+
(f !== "fork" || this.host.forkSession !== undefined) &&
|
|
884
|
+
(f !== "revert" || this.canRevert()),
|
|
885
|
+
),
|
|
829
886
|
harness: HARNESS_OPENCODE,
|
|
830
887
|
harnessVersion: this.host.opencodeVersion,
|
|
831
888
|
evidence: "native",
|
|
@@ -905,6 +962,13 @@ export class Spoke {
|
|
|
905
962
|
private async conversationOn(sessionID: string): Promise<ResultFor["conversation"]> {
|
|
906
963
|
const entry = this.conversations.get(sessionID)
|
|
907
964
|
const session = await this.host.getSession(sessionID)
|
|
965
|
+
// (CON-04) lockAgent: conversation mode is recorded, but the session keeps its agent and model (the Concierge
|
|
966
|
+
// never escapes its restricted agent, and the context hook adds no orchestrator parts).
|
|
967
|
+
// A session on the concierge agent is locked too (a spoke that restarted before the hub re-sent the context).
|
|
968
|
+
if (this.contexts.locked(sessionID) || session.agent === CONCIERGE_AGENT) {
|
|
969
|
+
this.conversations.set(sessionID, {})
|
|
970
|
+
return { agent: session.agent ?? (await this.defaultAgent()) }
|
|
971
|
+
}
|
|
908
972
|
this.rememberModel(sessionID, session.model)
|
|
909
973
|
const current = session.agent
|
|
910
974
|
const models = await this.voiceModelOn(sessionID, session, entry)
|
|
@@ -1023,9 +1087,10 @@ export class Spoke {
|
|
|
1023
1087
|
return this.modelPrefsResult(req.sessionID)
|
|
1024
1088
|
}
|
|
1025
1089
|
case "timeline": {
|
|
1026
|
-
// A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly.
|
|
1090
|
+
// A first page reads OpenCode (it shows the newest state); following pages reuse those rows briefly. A
|
|
1091
|
+
// staged revert hides its message and everything after it (OpenCode keeps them until it is committed).
|
|
1027
1092
|
const first = req.cursor === undefined && req.before === undefined
|
|
1028
|
-
const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.
|
|
1093
|
+
const items = await this.timelines.get(req.sessionID, async () => timelineItems(req.sessionID, await this.shownMessages(req.sessionID)), first)
|
|
1029
1094
|
if (req.before !== undefined || req.order === "desc") return newestPage(items, req.limit ?? 50, req.before) satisfies ResultFor["timeline"]
|
|
1030
1095
|
const offset = req.cursor === undefined ? 0 : Number(req.cursor)
|
|
1031
1096
|
if (!Number.isInteger(offset) || offset < 0) throw new Error("Invalid timeline cursor")
|
|
@@ -1069,6 +1134,134 @@ export class Spoke {
|
|
|
1069
1134
|
return this.setConversation(req.sessionID, req.on)
|
|
1070
1135
|
case "deliver":
|
|
1071
1136
|
return this.deliver(req)
|
|
1137
|
+
case "sessionContext": {
|
|
1138
|
+
// Only a session this process reports (a context needs a real session).
|
|
1139
|
+
if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
|
|
1140
|
+
const result = this.contexts.handle(req)
|
|
1141
|
+
// The hub sees the new revision (and the session's agent) in the next sessions frame.
|
|
1142
|
+
void this.refreshLockedAgents().finally(() => this.scheduleSnapshot())
|
|
1143
|
+
return result
|
|
1144
|
+
}
|
|
1145
|
+
case "profileApply":
|
|
1146
|
+
// Only a session this process reports (mode "always" exposes any ID; a profile needs a real session).
|
|
1147
|
+
if (!this.tracker.has(req.sessionID)) throw new Error(UNKNOWN_SESSION)
|
|
1148
|
+
return this.profiles.handle(req)
|
|
1149
|
+
case "fork":
|
|
1150
|
+
return this.fork(req.sessionID, req.messageID)
|
|
1151
|
+
case "revertPreview": {
|
|
1152
|
+
const messages = await this.host.context(req.sessionID)
|
|
1153
|
+
const index = userIndex(messages, req.messageID)
|
|
1154
|
+
if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
|
|
1155
|
+
return revertPreview(messages, index) satisfies ResultFor["revertPreview"]
|
|
1156
|
+
}
|
|
1157
|
+
case "revert":
|
|
1158
|
+
return this.revert(req.sessionID, req.messageID, req.files)
|
|
1159
|
+
case "revertRestore":
|
|
1160
|
+
if (!this.canRevert()) throw new Error(CANNOT_REVERT)
|
|
1161
|
+
await this.host.revertClear!(req.sessionID)
|
|
1162
|
+
return this.revertDone(req.sessionID)
|
|
1163
|
+
case "revertCommit":
|
|
1164
|
+
if (!this.canRevert()) throw new Error(CANNOT_REVERT)
|
|
1165
|
+
await this.host.revertCommit!(req.sessionID)
|
|
1166
|
+
return this.revertDone(req.sessionID)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
private canRevert(): boolean {
|
|
1171
|
+
return this.host.revertStage !== undefined && this.host.revertClear !== undefined && this.host.revertCommit !== undefined
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Forks the session before one of its user messages (feature "fork") and reports the new session at once (the
|
|
1176
|
+
* snapshot goes out before the reply). The result carries the message's text and attachments for the composer.
|
|
1177
|
+
*/
|
|
1178
|
+
private async fork(sessionID: string, messageID: string): Promise<ResultFor["fork"]> {
|
|
1179
|
+
const fork = this.host.forkSession?.bind(this.host)
|
|
1180
|
+
if (!fork) throw new Error(CANNOT_FORK)
|
|
1181
|
+
const messages = await this.host.context(sessionID)
|
|
1182
|
+
const index = userIndex(messages, messageID)
|
|
1183
|
+
if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
|
|
1184
|
+
const session = await fork({ sessionID, before: messageID })
|
|
1185
|
+
if (typeof session?.id !== "string" || !session.id) throw new Error("OpenCode did not report the forked session")
|
|
1186
|
+
const title = typeof session.title === "string" ? session.title : ""
|
|
1187
|
+
const directory = typeof session.location?.directory === "string" && session.location.directory ? session.location.directory : (this.tracker.directoryOf(sessionID) ?? this.host.directory)
|
|
1188
|
+
const now = Date.now()
|
|
1189
|
+
const source = this.tracker.snapshot(now).find((s) => s.id === sessionID)
|
|
1190
|
+
this.tracker.observe({ type: "session.created", data: { sessionID: session.id, title, location: { directory } } }, now)
|
|
1191
|
+
this.tracker.setInfo(session.id, {
|
|
1192
|
+
title,
|
|
1193
|
+
directory,
|
|
1194
|
+
updated: now,
|
|
1195
|
+
...(source?.project ? { project: source.project } : {}),
|
|
1196
|
+
...(source?.projectName ? { projectName: source.projectName } : {}),
|
|
1197
|
+
})
|
|
1198
|
+
if (this.config.mode === "session") this.setSession(session.id, true)
|
|
1199
|
+
this.sendSnapshot()
|
|
1200
|
+
return { sessionID: session.id, ...(title ? { title } : {}), ...userContent(messages[index]) }
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Stages a revert from one of the session's user messages (feature "revert"), with the files restored when
|
|
1205
|
+
* `files`, and reports it at once. Refused while the session runs (OpenCode refuses it too).
|
|
1206
|
+
*/
|
|
1207
|
+
private async revert(sessionID: string, messageID: string, files: boolean): Promise<ResultFor["revert"]> {
|
|
1208
|
+
if (!this.canRevert()) throw new Error(CANNOT_REVERT)
|
|
1209
|
+
const messages = await this.host.context(sessionID)
|
|
1210
|
+
const index = userIndex(messages, messageID)
|
|
1211
|
+
if (index < 0) throw new Error(NOT_A_USER_MESSAGE)
|
|
1212
|
+
if (this.running.has(sessionID) || this.tracker.isBusy(sessionID)) throw new Error(REVERT_BUSY)
|
|
1213
|
+
let staged: unknown
|
|
1214
|
+
try {
|
|
1215
|
+
staged = await this.host.revertStage!({ sessionID, messageID, files })
|
|
1216
|
+
} catch (cause) {
|
|
1217
|
+
if (busyError(cause)) throw new Error(REVERT_BUSY)
|
|
1218
|
+
throw cause
|
|
1219
|
+
}
|
|
1220
|
+
const revert = revertSummary(staged, messages) ?? revertSummary({ messageID }, messages)!
|
|
1221
|
+
this.timelines.invalidate(sessionID)
|
|
1222
|
+
// Sent at once even when OpenCode's staged event already set it (its snapshot may still be debounced).
|
|
1223
|
+
this.tracker.setRevert(sessionID, revert)
|
|
1224
|
+
this.sendSnapshot()
|
|
1225
|
+
return { revert, ...userContent(messages[index]) }
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** After a restore or commit: no revert is staged; reported at once. */
|
|
1229
|
+
private revertDone(sessionID: string): Record<string, never> {
|
|
1230
|
+
this.timelines.invalidate(sessionID)
|
|
1231
|
+
this.tracker.setRevert(sessionID, undefined)
|
|
1232
|
+
this.sendSnapshot()
|
|
1233
|
+
return {}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* The session's messages a timeline shows: all of them, or with a staged revert those before its message. The
|
|
1238
|
+
* revert is read from OpenCode's session (so one staged before this process saw it counts) and reported.
|
|
1239
|
+
*/
|
|
1240
|
+
private async shownMessages(sessionID: string): Promise<readonly unknown[]> {
|
|
1241
|
+
const [messages, session] = await Promise.all([
|
|
1242
|
+
this.host.context(sessionID),
|
|
1243
|
+
this.canRevert() ? this.host.getSession(sessionID).catch(() => undefined) : Promise.resolve(undefined),
|
|
1244
|
+
])
|
|
1245
|
+
if (!this.canRevert()) return messages
|
|
1246
|
+
const raw = session ? (session as { revert?: unknown }).revert : undefined
|
|
1247
|
+
const revert = session ? revertSummary(raw, messages) : this.tracker.revertOf(sessionID)
|
|
1248
|
+
if (session && this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
|
|
1249
|
+
return visibleMessages(messages, revert?.messageID)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/** A revert staged, restored or committed anywhere (this hub, the terminal, another client): reported. */
|
|
1253
|
+
private async observeRevert(event: EventLike, sessionID: string): Promise<void> {
|
|
1254
|
+
if (!this.tracker.has(sessionID)) return
|
|
1255
|
+
if (event.type !== "session.revert.staged") {
|
|
1256
|
+
if (this.tracker.setRevert(sessionID, undefined)) this.scheduleSnapshot()
|
|
1257
|
+
return
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
const raw = (event.data as Record<string, unknown>).revert
|
|
1261
|
+
const revert = revertSummary(raw, await this.host.context(sessionID))
|
|
1262
|
+
if (this.tracker.setRevert(sessionID, revert)) this.scheduleSnapshot()
|
|
1263
|
+
} catch (cause) {
|
|
1264
|
+
console.error("[hub] could not read a staged revert", cause)
|
|
1072
1265
|
}
|
|
1073
1266
|
}
|
|
1074
1267
|
|
|
@@ -1081,11 +1274,11 @@ export class Spoke {
|
|
|
1081
1274
|
if (!synthetic) throw new Error("This OpenCode process cannot take hub messages")
|
|
1082
1275
|
return this.deliveries.run(req.messageID, req.wake, async () => {
|
|
1083
1276
|
const subagent = (this.tracker.parentOf(req.sessionID) ?? (await this.host.getSession(req.sessionID).catch(() => undefined))?.parentID) !== undefined
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
})
|
|
1277
|
+
const busy = this.running.has(req.sessionID) || this.tracker.isBusy(req.sessionID)
|
|
1278
|
+
// A routine quiet message into a running turn would still cost a model call: refused, the hub keeps it
|
|
1279
|
+
// pending and sends it again after the turn (the turn's end sends a sessions frame). Nothing is admitted.
|
|
1280
|
+
if (busy && deferredWhileBusy(req, subagent)) throw new Error(QUIET_WHILE_BUSY)
|
|
1281
|
+
const plan = planDelivery(req, { busy, formOpen: this.tracker.formOpenIn(req.sessionID), subagent })
|
|
1089
1282
|
const id = harnessMessageID(req.messageID)
|
|
1090
1283
|
// Marked before sending: OpenCode may report it delivered before the call returns.
|
|
1091
1284
|
this.origins.hub(id)
|
|
@@ -1488,6 +1681,7 @@ export class Spoke {
|
|
|
1488
1681
|
if (event.type === "session.deleted" && typeof sessionID === "string") {
|
|
1489
1682
|
this.tokens.delete(sessionID)
|
|
1490
1683
|
this.actTokens.delete(sessionID)
|
|
1684
|
+
this.profiles.forget(sessionID)
|
|
1491
1685
|
}
|
|
1492
1686
|
if (event.type === "form.replied" || event.type === "form.cancelled") {
|
|
1493
1687
|
const formID = (event.data as Record<string, unknown> | undefined)?.id
|
|
@@ -1502,7 +1696,9 @@ export class Spoke {
|
|
|
1502
1696
|
if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
|
|
1503
1697
|
}
|
|
1504
1698
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
1505
|
-
if (
|
|
1699
|
+
if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
|
|
1700
|
+
if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
|
|
1701
|
+
if (this.host.listRecent && LISTING_EVENTS.has(event.type) && !(event.data as any)?.parentID) {
|
|
1506
1702
|
this.scheduleSeed()
|
|
1507
1703
|
}
|
|
1508
1704
|
void this.fetchInfo()
|
|
@@ -1510,7 +1706,11 @@ export class Spoke {
|
|
|
1510
1706
|
// Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
|
|
1511
1707
|
// get their session from the job.
|
|
1512
1708
|
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))
|
|
1709
|
+
if (compact && shouldForward(this.config, this.tracker, compact)) {
|
|
1710
|
+
this.announce(compact)
|
|
1711
|
+
this.connection.send({ t: "event", event: compact })
|
|
1712
|
+
if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
|
|
1713
|
+
}
|
|
1514
1714
|
}
|
|
1515
1715
|
} catch (cause) {
|
|
1516
1716
|
console.error("[hub] event handling failed", cause)
|
|
@@ -1599,13 +1799,50 @@ export class Spoke {
|
|
|
1599
1799
|
}
|
|
1600
1800
|
|
|
1601
1801
|
private async seed(): Promise<void> {
|
|
1802
|
+
if (this.listing) {
|
|
1803
|
+
this.relist = true
|
|
1804
|
+
return
|
|
1805
|
+
}
|
|
1806
|
+
this.listing = true
|
|
1807
|
+
try {
|
|
1808
|
+
do {
|
|
1809
|
+
this.relist = false
|
|
1810
|
+
await this.listOnce()
|
|
1811
|
+
} while (this.relist && this.started)
|
|
1812
|
+
} finally {
|
|
1813
|
+
this.listing = false
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* One listing, bounded by listingTimeoutMs: a server that accepts and never answers must not stop later listings
|
|
1819
|
+
* (seed() runs one at a time). A timed-out listing is abandoned (logged once until a listing completes again);
|
|
1820
|
+
* if it settles later its result is dropped, so it never overwrites a newer listing.
|
|
1821
|
+
*/
|
|
1822
|
+
private async listOnce(): Promise<void> {
|
|
1823
|
+
const generation = ++this.listingGeneration
|
|
1824
|
+
const limit = this.options.listingTimeoutMs ?? LISTING_TIMEOUT_MS
|
|
1825
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1826
|
+
const timedOut = Symbol("timeout")
|
|
1602
1827
|
try {
|
|
1603
1828
|
const keep = this.keep()
|
|
1604
1829
|
const { days, max } = this.config.list
|
|
1605
|
-
const
|
|
1830
|
+
const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep] }))
|
|
1831
|
+
listed.catch(() => {}) // an abandoned listing that fails later is not an unhandled rejection
|
|
1832
|
+
const result = await Promise.race([listed, new Promise<typeof timedOut>((resolve) => (timer = setTimeout(() => resolve(timedOut), limit)))])
|
|
1833
|
+
if (result === timedOut) {
|
|
1834
|
+
if (!this.listingHung) console.error(`[hub] listing recent sessions timed out after ${limit} ms; listing again later`)
|
|
1835
|
+
this.listingHung = true
|
|
1836
|
+
return
|
|
1837
|
+
}
|
|
1838
|
+
this.listingHung = false
|
|
1839
|
+
if (generation !== this.listingGeneration) return
|
|
1840
|
+
const sessions = result ?? []
|
|
1606
1841
|
if (this.tracker.applyListing(sessions, Date.now(), keep)) this.scheduleSnapshot()
|
|
1607
1842
|
} catch (cause) {
|
|
1608
1843
|
console.error("[hub] could not list recent sessions", errorText(cause))
|
|
1844
|
+
} finally {
|
|
1845
|
+
clearTimeout(timer)
|
|
1609
1846
|
}
|
|
1610
1847
|
}
|
|
1611
1848
|
|
|
@@ -1625,9 +1862,68 @@ export class Spoke {
|
|
|
1625
1862
|
}, this.options.snapshotDebounceMs ?? 500)
|
|
1626
1863
|
}
|
|
1627
1864
|
|
|
1865
|
+
/**
|
|
1866
|
+
* (CON-04) The agent a locked (Concierge) session runs on, as the context hook or OpenCode reports it: a change
|
|
1867
|
+
* goes to the hub in the next sessions frame (the hub suspends a Concierge on another agent).
|
|
1868
|
+
*/
|
|
1869
|
+
noteAgent(sessionID: string, agent: string | undefined): void {
|
|
1870
|
+
if (this.contexts.seenAgent(sessionID, agent)) this.scheduleSnapshot()
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/** Reads the current agent of every locked session this process reports (OpenCode's session record). */
|
|
1874
|
+
private async refreshLockedAgents(): Promise<void> {
|
|
1875
|
+
for (const sessionID of this.contexts.lockedSessions()) {
|
|
1876
|
+
if (!this.tracker.has(sessionID)) continue
|
|
1877
|
+
try {
|
|
1878
|
+
this.noteAgent(sessionID, (await this.host.getSession(sessionID)).agent)
|
|
1879
|
+
} catch {
|
|
1880
|
+
// Gone or unreachable: the next refresh tries again.
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1628
1885
|
private sendSnapshot(): void {
|
|
1886
|
+
if (this.snapshotTimer) clearTimeout(this.snapshotTimer)
|
|
1887
|
+
this.snapshotTimer = undefined
|
|
1629
1888
|
const tui = this.presenceBook.open(Date.now())
|
|
1630
|
-
|
|
1889
|
+
const sessions = browserSessions(this.config, this.tracker, Date.now(), { terminal: (id) => this.terminal(id, tui) }).map((s) => ({ ...s, ...this.contexts.report(s.id) }))
|
|
1890
|
+
this.connection?.send({ t: "sessions", sessions })
|
|
1891
|
+
this.announced = new Set(sessions.map((s) => s.id))
|
|
1892
|
+
this.resendUnheardFailures()
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/**
|
|
1896
|
+
* A run that failed while its session's details were still being fetched went out before any snapshot listed the
|
|
1897
|
+
* session, so the hub dropped it: once a snapshot lists the session, its failure goes out again (unless a run
|
|
1898
|
+
* started since).
|
|
1899
|
+
*/
|
|
1900
|
+
private resendUnheardFailures(): void {
|
|
1901
|
+
for (const id of [...this.unheardFailures]) {
|
|
1902
|
+
const error = this.tracker.errorOf(id)
|
|
1903
|
+
if (!error || !this.tracker.has(id)) {
|
|
1904
|
+
this.unheardFailures.delete(id)
|
|
1905
|
+
continue
|
|
1906
|
+
}
|
|
1907
|
+
if (!this.announced.has(id) || this.connection?.state !== "connected") continue
|
|
1908
|
+
this.unheardFailures.delete(id)
|
|
1909
|
+
const event = { type: "session.execution.failed", data: { sessionID: id, error } }
|
|
1910
|
+
if (shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/**
|
|
1915
|
+
* Sends the snapshot now when an event is about a session the hub was not told about yet: the hub drops a run's
|
|
1916
|
+
* events (a failure, a permission) for sessions it does not know, and a run can fail within milliseconds of
|
|
1917
|
+
* session.created (a model that does not exist), well before the debounced snapshot. Creating, renaming or
|
|
1918
|
+
* deleting a session alone waits for the debounce (a burst of subagents sends one snapshot).
|
|
1919
|
+
*/
|
|
1920
|
+
private announce(event: { type: string; data: Record<string, unknown> }): void {
|
|
1921
|
+
if (event.type === "session.created" || event.type === "session.renamed" || event.type === "session.deleted") return
|
|
1922
|
+
const sessionID = eventSessionID(event)
|
|
1923
|
+
if (!sessionID || this.announced.has(sessionID) || !this.tracker.hasInfo(sessionID)) return
|
|
1924
|
+
this.sendSnapshot()
|
|
1925
|
+
// Once per session even when the snapshot's limits leave it out.
|
|
1926
|
+
this.announced.add(sessionID)
|
|
1631
1927
|
}
|
|
1632
1928
|
|
|
1633
1929
|
private every(ms: number, run: () => void): void {
|
|
@@ -1637,6 +1933,9 @@ export class Spoke {
|
|
|
1637
1933
|
}
|
|
1638
1934
|
}
|
|
1639
1935
|
|
|
1936
|
+
/** Longest a session listing may take (SpokeOptions.listingTimeoutMs). */
|
|
1937
|
+
const LISTING_TIMEOUT_MS = 60_000
|
|
1938
|
+
|
|
1640
1939
|
/** Directories whose shells are listed when seeding. */
|
|
1641
1940
|
const SEED_DIRECTORIES = 20
|
|
1642
1941
|
|
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
|
|