@huynle/hub 0.8.0 → 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 +213 -1
- package/README.md +84 -15
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +649 -242
- package/packages/cli/package.json +1 -1
- 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 +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +7 -3
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/teams-mcp.ts +59 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +7 -1
- package/packages/protocol/src/launch.ts +14 -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/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/acting.ts +3 -0
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +101 -27
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +26 -2
- 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 +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +4 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +4 -3
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +38 -15
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +26 -1
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +22 -0
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +39 -18
- package/src/migrate.ts +109 -3
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/hub-tool.ts +24 -10
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/session-context.ts +107 -27
- package/src/spoke/spoke.ts +13 -5
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -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-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/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
package/src/spoke/localhub.ts
CHANGED
|
@@ -10,7 +10,9 @@ import path from "node:path"
|
|
|
10
10
|
import { fileURLToPath } from "node:url"
|
|
11
11
|
import { hubEnv } from "../env"
|
|
12
12
|
import { isLoopbackURL } from "../loopback"
|
|
13
|
-
import {
|
|
13
|
+
import { migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
|
|
14
|
+
|
|
15
|
+
const CONCIERGE_LOCK = "concierge-sessions.json"
|
|
14
16
|
|
|
15
17
|
export interface ChildLike {
|
|
16
18
|
readonly pid?: number
|
|
@@ -23,7 +25,7 @@ export type SpawnLike = (command: string, args: readonly string[], options: obje
|
|
|
23
25
|
|
|
24
26
|
export interface LocalHubOptions {
|
|
25
27
|
/**
|
|
26
|
-
* Where hub.pid and hub.log live (default $HUB_STATE_DIR or ~/.local/state/hub, migrated from
|
|
28
|
+
* Where hub.pid and hub.log live (default $HUB_STATE_DIR, else $XDG_STATE_HOME/hub or ~/.local/state/hub, migrated from
|
|
27
29
|
* ~/.local/state/opencode-hub or opencode-voice first so the hub does not find a fresh, empty state directory).
|
|
28
30
|
*/
|
|
29
31
|
readonly stateDir?: string
|
|
@@ -86,7 +88,15 @@ export function hubScript(root: string = repoRoot()): string {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
export function defaultStateDir(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): string {
|
|
89
|
-
return
|
|
91
|
+
return resolveHubStateDir(env, home).dir
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The Concierge lock file (written in the state dir) and the other default places' lock files (read too: fail closed). */
|
|
95
|
+
export function conciergeLockFiles(env: Readonly<Record<string, string | undefined>> = process.env, home: string = homedir()): { file: string; read: string[] } {
|
|
96
|
+
const { candidates, source } = resolveHubStateDir(env, home)
|
|
97
|
+
const [dir, ...others] = candidates
|
|
98
|
+
// An explicit HUB_STATE_DIR is an isolated instance (a preview or a test): it never reads the user's default dirs.
|
|
99
|
+
return { file: path.join(dir!, CONCIERGE_LOCK), read: source === "env" ? [] : others.map((d) => path.join(d, CONCIERGE_LOCK)) }
|
|
90
100
|
}
|
|
91
101
|
|
|
92
102
|
/** The runtime for this process: probes the real executable, PATH and home directory. */
|
|
@@ -152,7 +162,7 @@ export async function ensureLocalHub(server: string, options: LocalHubOptions =
|
|
|
152
162
|
if (!(options.webBuilt ?? hasWebBuild)(root)) return { status: "failed", error: WEB_NOT_BUILT }
|
|
153
163
|
|
|
154
164
|
if (options.stateDir === undefined && !hubEnv(env).HUB_STATE_DIR) {
|
|
155
|
-
|
|
165
|
+
migrateStateDir(stateLegacyPaths(env, home), stateDir, { skip: ["hub.pid"], ...(options.log ? { log: options.log } : {}) })
|
|
156
166
|
}
|
|
157
167
|
mkdirSync(stateDir, { recursive: true, mode: 0o700 })
|
|
158
168
|
|
package/src/spoke/locations.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface PermissionLocation {
|
|
|
10
10
|
get(input: { sessionID: string; requestID: string }): Promise<unknown>
|
|
11
11
|
/** `message`: a reject's reason (OpenCode's reply message). */
|
|
12
12
|
reply(input: { sessionID: string; requestID: string; decision: PermissionDecision; message?: string }): Promise<unknown>
|
|
13
|
+
vcs?: { diff(): Promise<ReadonlyArray<unknown>>; branch(): Promise<string | undefined> }
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -33,3 +34,42 @@ export async function replyPermission(
|
|
|
33
34
|
}
|
|
34
35
|
return "gone"
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
/** A directory's branch and working-copy changes (undefined: nothing could read them). */
|
|
39
|
+
export interface VcsReader {
|
|
40
|
+
diff(directory: string): Promise<ReadonlyArray<unknown> | undefined>
|
|
41
|
+
branch(directory: string): Promise<string | undefined>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads a directory's VCS state through the plugin instance whose location is that directory, else the own
|
|
46
|
+
* server's API (which honours the location), else not at all. OpenCode's plugin vcs API answers for the
|
|
47
|
+
* instance's own location whatever location is passed, so the first instance (the background service's is
|
|
48
|
+
* its $HOME) would report another directory's branch and changes. A read that takes longer than `timeoutMs` (a hung
|
|
49
|
+
* server) answers undefined, so the session summary goes on without branch and changes.
|
|
50
|
+
*/
|
|
51
|
+
/** How long a branch or changes read may take before the summary goes on without it (a hung server). */
|
|
52
|
+
export const VCS_READ_TIMEOUT_MS = 5_000
|
|
53
|
+
|
|
54
|
+
/** The value, or undefined once `ms` passed (the read is left to finish on its own). */
|
|
55
|
+
function within<T>(read: Promise<T | undefined>, ms: number): Promise<T | undefined> {
|
|
56
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
57
|
+
const late = new Promise<undefined>((resolve) => {
|
|
58
|
+
timer = setTimeout(() => resolve(undefined), ms)
|
|
59
|
+
})
|
|
60
|
+
return Promise.race([read.catch(() => undefined), late]).finally(() => clearTimeout(timer))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function vcsReader(locations: () => readonly PermissionLocation[], server: () => Promise<VcsReader | undefined>, timeoutMs: number = VCS_READ_TIMEOUT_MS): VcsReader {
|
|
64
|
+
const local = (directory: string) => locations().find((l) => l.directory === directory && l.vcs)?.vcs
|
|
65
|
+
return {
|
|
66
|
+
diff: async (directory) => {
|
|
67
|
+
const vcs = local(directory)
|
|
68
|
+
return within(vcs ? vcs.diff() : server().then((reader) => reader?.diff(directory)), timeoutMs)
|
|
69
|
+
},
|
|
70
|
+
branch: async (directory) => {
|
|
71
|
+
const vcs = local(directory)
|
|
72
|
+
return within(vcs ? vcs.branch() : server().then((reader) => reader?.branch(directory)), timeoutMs)
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// the plugin's state dir (lockFile), so a restarted OpenCode locks the session before the hub reconnects. The agent
|
|
17
17
|
// a locked session was last seen on is reported to the hub (SessionInfo.agent), which suspends a Concierge found on
|
|
18
18
|
// another agent.
|
|
19
|
-
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"
|
|
19
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
20
20
|
import path from "node:path"
|
|
21
21
|
import { CONCIERGE_TOOLS, SESSION_CONTEXT_KEYS, type SessionContextRequest, type SessionContextResult, toolDenied } from "../../packages/protocol/src/concierge.ts"
|
|
22
22
|
|
|
@@ -43,6 +43,62 @@ export interface SessionContextsOptions {
|
|
|
43
43
|
readonly maxSessions?: number
|
|
44
44
|
/** Where locked session IDs persist (the plugin's state dir); in memory only without it. */
|
|
45
45
|
readonly lockFile?: string
|
|
46
|
+
/** (FX-07) How long a write waits for the other processes' exclusive lock before it writes anyway. */
|
|
47
|
+
readonly lockWaitMs?: number
|
|
48
|
+
/**
|
|
49
|
+
* Other lock files read (never written): the other default state dir places (FX-08: a lock written before an
|
|
50
|
+
* upgrade moved the state dir still locks the session; fail closed).
|
|
51
|
+
*/
|
|
52
|
+
readonly readLockFiles?: readonly string[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** (FX-07) A `<lockFile>.lock` older than this belongs to a process that died while writing: it is broken. */
|
|
56
|
+
const WRITE_LOCK_STALE_MS = 10_000
|
|
57
|
+
const WRITE_LOCK_WAIT_MS = 2_000
|
|
58
|
+
const pause = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* (FX-07) Runs `write` holding `<file>.lock` (created exclusively), so the read-merge-write of every OpenCode process
|
|
62
|
+
* on the machine is serialized and none drops another's locks. A stale lock (its holder died) is broken; after
|
|
63
|
+
* `waitMs` the write runs anyway (the lock still holds in this process, and the write merges what it reads).
|
|
64
|
+
*/
|
|
65
|
+
function underWriteLock(file: string, waitMs: number, write: () => void): void {
|
|
66
|
+
const lock = `${file}.lock`
|
|
67
|
+
const deadline = Date.now() + waitMs
|
|
68
|
+
let held = false
|
|
69
|
+
for (;;) {
|
|
70
|
+
try {
|
|
71
|
+
closeSync(openSync(lock, "wx", 0o600))
|
|
72
|
+
held = true
|
|
73
|
+
break
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
if ((cause as NodeJS.ErrnoException).code !== "EEXIST") break
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
if (Date.now() - statSync(lock).mtimeMs > WRITE_LOCK_STALE_MS) {
|
|
79
|
+
unlinkSync(lock)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
continue // released meanwhile
|
|
84
|
+
}
|
|
85
|
+
if (Date.now() >= deadline) {
|
|
86
|
+
console.error("[hub] the Concierge session lock file stayed busy: writing without it")
|
|
87
|
+
break
|
|
88
|
+
}
|
|
89
|
+
pause(2 + Math.floor(Math.random() * 5))
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
write()
|
|
93
|
+
} finally {
|
|
94
|
+
if (held) {
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(lock)
|
|
97
|
+
} catch {
|
|
98
|
+
// already gone
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
46
102
|
}
|
|
47
103
|
|
|
48
104
|
export class SessionContexts {
|
|
@@ -60,46 +116,62 @@ export class SessionContexts {
|
|
|
60
116
|
this.locks = new Set(this.load())
|
|
61
117
|
}
|
|
62
118
|
|
|
119
|
+
/** Every lock file read: the written one first, then the read-only ones. */
|
|
120
|
+
private files(): string[] {
|
|
121
|
+
return [...(this.options.lockFile ? [this.options.lockFile] : []), ...(this.options.readLockFiles ?? [])]
|
|
122
|
+
}
|
|
123
|
+
|
|
63
124
|
/** Adds the locks another process wrote since the last read (locks are never removed). */
|
|
64
125
|
private refresh(): void {
|
|
65
|
-
const
|
|
66
|
-
if (!
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
126
|
+
const files = this.files()
|
|
127
|
+
if (!files.length) return
|
|
128
|
+
const stamp = files
|
|
129
|
+
.map((file) => {
|
|
130
|
+
try {
|
|
131
|
+
return stampOf(file)
|
|
132
|
+
} catch {
|
|
133
|
+
return "-"
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
.join("|")
|
|
73
137
|
if (stamp === this.lockStamp) return
|
|
74
138
|
this.lockStamp = stamp
|
|
75
139
|
for (const id of this.load()) this.locks.add(id)
|
|
76
140
|
}
|
|
77
141
|
|
|
142
|
+
/** The union of the lock files' sessions (a missing or corrupt file adds none). */
|
|
78
143
|
private load(): string[] {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
144
|
+
return this.files().flatMap((file) => {
|
|
145
|
+
try {
|
|
146
|
+
const raw = JSON.parse(readFileSync(file, "utf8")) as { v?: unknown; sessions?: unknown }
|
|
147
|
+
return raw.v === 1 && Array.isArray(raw.sessions) ? raw.sessions.filter((id): id is string => typeof id === "string" && id.length > 0 && id.length <= 200) : []
|
|
148
|
+
} catch {
|
|
149
|
+
return []
|
|
150
|
+
}
|
|
151
|
+
})
|
|
86
152
|
}
|
|
87
153
|
|
|
88
154
|
private lock(sessionID: string): void {
|
|
89
155
|
this.refresh()
|
|
90
156
|
if (this.locks.has(sessionID)) return
|
|
91
|
-
// Merge with what other processes wrote: never drop their locks.
|
|
92
|
-
for (const id of this.load()) this.locks.add(id)
|
|
93
157
|
this.locks.add(sessionID)
|
|
94
|
-
while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
|
|
95
158
|
const file = this.options.lockFile
|
|
96
|
-
if (!file) return
|
|
159
|
+
if (!file) return this.boundLocks()
|
|
97
160
|
try {
|
|
98
161
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
162
|
+
underWriteLock(file, this.options.lockWaitMs ?? WRITE_LOCK_WAIT_MS, () => {
|
|
163
|
+
// Merge with what other processes wrote (read under the write lock): never drop their locks.
|
|
164
|
+
for (const id of this.load()) this.locks.add(id)
|
|
165
|
+
// The newest lock last, so the bound never drops it.
|
|
166
|
+
this.locks.delete(sessionID)
|
|
167
|
+
this.locks.add(sessionID)
|
|
168
|
+
this.boundLocks()
|
|
169
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
170
|
+
writeFileSync(tmp, `${JSON.stringify({ v: 1, sessions: [...this.locks] })}\n`, { mode: 0o600 })
|
|
171
|
+
renameSync(tmp, file)
|
|
172
|
+
})
|
|
173
|
+
this.lockStamp = ""
|
|
174
|
+
this.refresh()
|
|
103
175
|
} catch (cause) {
|
|
104
176
|
// The lock still holds in this process; the hub re-sends the context after a restart.
|
|
105
177
|
console.error(`[hub] could not persist the Concierge session lock: ${(cause as Error).message}`)
|
|
@@ -126,6 +198,11 @@ export class SessionContexts {
|
|
|
126
198
|
return { revision: req.revision }
|
|
127
199
|
}
|
|
128
200
|
|
|
201
|
+
/** At most LOCKS_MAX locks (the oldest dropped). */
|
|
202
|
+
private boundLocks(): void {
|
|
203
|
+
while (this.locks.size > LOCKS_MAX) this.locks.delete(this.locks.values().next().value as string)
|
|
204
|
+
}
|
|
205
|
+
|
|
129
206
|
private bound(map: Map<string, unknown>): void {
|
|
130
207
|
while (map.size > this.maxSessions) map.delete(map.keys().next().value as string)
|
|
131
208
|
}
|
|
@@ -166,11 +243,14 @@ export class SessionContexts {
|
|
|
166
243
|
return true
|
|
167
244
|
}
|
|
168
245
|
|
|
169
|
-
/**
|
|
170
|
-
|
|
246
|
+
/**
|
|
247
|
+
* What the sessions frame says about the session (locked ones only): locked (FX-07: from the very first frame, even
|
|
248
|
+
* after a restart, before its agent is read or the hub re-sends a context), its agent and context revision.
|
|
249
|
+
*/
|
|
250
|
+
report(sessionID: string): { locked?: true; agent?: string; contextRevision?: number } {
|
|
171
251
|
if (!this.locks.has(sessionID)) return {}
|
|
172
252
|
const agent = this.agents.get(sessionID)
|
|
173
253
|
const revision = this.contexts.get(sessionID)?.revision
|
|
174
|
-
return { ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
|
|
254
|
+
return { locked: true, ...(agent ? { agent } : {}), ...(revision !== undefined ? { contextRevision: revision } : {}) }
|
|
175
255
|
}
|
|
176
256
|
}
|
package/src/spoke/spoke.ts
CHANGED
|
@@ -147,13 +147,14 @@ export interface SpokeHost {
|
|
|
147
147
|
* only those of the locations it loaded. Absent (a TUI's own server, which this process cannot reach): the
|
|
148
148
|
* process reports only the sessions it sees active.
|
|
149
149
|
*/
|
|
150
|
-
|
|
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>>
|
|
151
152
|
/** Creates a session in a directory (service processes only). */
|
|
152
153
|
createSession?(input: { directory: string; title?: string }): Promise<{ id: string }>
|
|
153
154
|
/** MCP servers and their status for a directory (raw OpenCode McpServer list). */
|
|
154
155
|
mcpList?(directory: string): Promise<ReadonlyArray<unknown>>
|
|
155
|
-
/** Working-tree changes of a directory (raw OpenCode FileDiffInfo list). */
|
|
156
|
-
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>
|
|
157
158
|
/** Current branch of a directory. */
|
|
158
159
|
vcsBranch?(directory: string): Promise<string | undefined>
|
|
159
160
|
/** (HWA-16) A location's local skill and command names (they win over a hub profile's); absent: none applied. */
|
|
@@ -187,6 +188,8 @@ const ANSWER_IN_TERMINAL = "This OpenCode process cannot answer questions from t
|
|
|
187
188
|
export interface SpokeOptions {
|
|
188
189
|
/** (CON-04) Where Concierge session locks persist (the plugin's state dir); memory only without it. */
|
|
189
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[]
|
|
190
193
|
readonly configFile: string
|
|
191
194
|
readonly machineName: string
|
|
192
195
|
readonly kind: ProcessKind
|
|
@@ -410,7 +413,10 @@ export class Spoke {
|
|
|
410
413
|
) {
|
|
411
414
|
this.config = options.launch ? launchVoiceConfig(options.launch, options.machineName) : readConfig(options.configFile, options.machineName)
|
|
412
415
|
this.hub = { pending: false, nextAt: 0, cooldown: options.localHubRetryMs ?? 30_000 }
|
|
413
|
-
this.contexts = new SessionContexts(
|
|
416
|
+
this.contexts = new SessionContexts({
|
|
417
|
+
...(options.conciergeLockFile ? { lockFile: options.conciergeLockFile } : {}),
|
|
418
|
+
...(options.conciergeLockReadFiles?.length ? { readLockFiles: options.conciergeLockReadFiles } : {}),
|
|
419
|
+
})
|
|
414
420
|
const localNames = host.localNames?.bind(host)
|
|
415
421
|
this.profiles = new OpenCodeProfiles({
|
|
416
422
|
isBusy: (id) => this.running.has(id) || this.tracker.isBusy(id),
|
|
@@ -1824,13 +1830,15 @@ export class Spoke {
|
|
|
1824
1830
|
const limit = this.options.listingTimeoutMs ?? LISTING_TIMEOUT_MS
|
|
1825
1831
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1826
1832
|
const timedOut = Symbol("timeout")
|
|
1833
|
+
const cancel = new AbortController()
|
|
1827
1834
|
try {
|
|
1828
1835
|
const keep = this.keep()
|
|
1829
1836
|
const { days, max } = this.config.list
|
|
1830
|
-
const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep] }))
|
|
1837
|
+
const listed = Promise.resolve(this.host.listRecent?.({ days, max, include: [...keep], signal: cancel.signal }))
|
|
1831
1838
|
listed.catch(() => {}) // an abandoned listing that fails later is not an unhandled rejection
|
|
1832
1839
|
const result = await Promise.race([listed, new Promise<typeof timedOut>((resolve) => (timer = setTimeout(() => resolve(timedOut), limit)))])
|
|
1833
1840
|
if (result === timedOut) {
|
|
1841
|
+
cancel.abort(new Error(`listing timed out after ${limit} ms`))
|
|
1834
1842
|
if (!this.listingHung) console.error(`[hub] listing recent sessions timed out after ${limit} ms; listing again later`)
|
|
1835
1843
|
this.listingHung = true
|
|
1836
1844
|
return
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{At as e,Et as t,ft as n,i as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{L as o,R as s,z as c}from"./index-lIOfSYck.js";import{t as l}from"./ArtifactWorkbench-NoFC5Sb2.js";var u=a(`<div class="feature-page av-page">`),d=a(`<a class="icon-btn av-back"href=/artifacts aria-label="All artifacts"title="All artifacts">`);function f(a){return(()=>{var f=u();return n(f,e(l,{get id(){return a.id},layout:`page`,get lead(){return[e(t,{get when(){return o()},get children(){return e(r,{icon:`menu`,label:`Open sidebar`,onClick:s})}}),(()=>{var t=d();return n(t,e(i,{name:`back`})),t})()]},get actions(){return e(r,{class:`av-wide`,icon:`split`,label:`Open in pane`,onClick:()=>c(a.id)})}})),f})()}export{f as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{At as e,Pt as t,ft as n,ht as r,r as i,yt as a}from"./ui-Dh1-78Cl.js";import{t as o}from"./ArtifactWorkbench-NoFC5Sb2.js";var s=a(`<a class=icon-btn aria-label="Full page"title="Open the artifact's full page">`);function c(a){return e(o,{get id(){return a.artifactID},layout:`pane`,onArtifact:e=>a.onTitle?.(e.title||`Untitled`),get actions(){return(()=>{var o=s();return n(o,e(i,{name:`expand`})),t(()=>r(o,`href`,`/artifacts/${encodeURIComponent(a.artifactID)}`)),o})()},get trailing(){return a.paneTools}})}export{c as ArtifactPane};
|