@huynle/hub 0.8.3 → 0.8.5
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 +80 -0
- package/README.md +40 -3
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/cli/dist/hub-cli.js +5288 -4415
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE2-CONTRACT.md +87 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +11 -43
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +19 -12
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +42 -6
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +18 -7
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +36 -19
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +19 -3
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +2 -5
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +37 -6
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +49 -26
- package/packages/cli/src/bootstrap/adapters/process-tree.ts +263 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +7 -4
- package/packages/cli/src/bootstrap/contracts/platform.ts +9 -5
- package/packages/cli/src/bootstrap/contracts/validate.ts +20 -4
- package/packages/cli/src/bootstrap/discovery/discover.ts +3 -1
- package/packages/cli/src/bootstrap/discovery/executables.ts +49 -14
- package/packages/cli/src/bootstrap/discovery/host.ts +16 -8
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +41 -5
- package/packages/cli/src/bootstrap/discovery/probe.ts +7 -3
- package/packages/cli/src/bootstrap/discovery/util.ts +42 -5
- package/packages/cli/src/bootstrap/enrollment/store.ts +13 -6
- package/packages/cli/src/bootstrap/preview/plan.ts +3 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +10 -3
- package/packages/cli/src/bootstrap/release/README.md +63 -9
- package/packages/cli/src/bootstrap/release/cli.ts +94 -16
- package/packages/cli/src/bootstrap/release/harness-pins.pending.json +24 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +3 -0
- package/packages/cli/src/bootstrap/release/pins.ts +60 -9
- package/packages/cli/src/bootstrap/transaction/builtin.ts +18 -6
- package/packages/cli/src/bootstrap/transaction/engine.ts +166 -12
- package/packages/cli/src/bootstrap/transaction/executor.ts +5 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +142 -3
- package/packages/cli/src/bootstrap/transaction/shim.ts +31 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +11 -6
- package/packages/cli/src/credentials.ts +5 -2
- package/packages/cli/src/main.ts +3 -2
- package/packages/cli/src/setup/commands.ts +96 -18
- package/packages/cli/src/setup/contributors.ts +45 -10
- package/packages/cli/src/setup/runtime.ts +25 -6
- package/packages/cli/src/setup/self-aside.ts +130 -0
- package/packages/codex/package.json +1 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/opencode-warm.ts +12 -4
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +7 -2
- 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/spoke-sdk/package.json +1 -1
- package/src/central/config.ts +12 -0
- package/src/central/http.ts +10 -0
- package/src/central/install/CONTRACT.md +66 -0
- package/src/central/install/install.ps1 +349 -0
- package/src/central/install/install.sh +266 -0
- package/src/central/install/render.ts +146 -0
- package/src/central/install/templates.ts +6 -0
- package/src/central/install/text-assets.d.ts +9 -0
- package/src/central/registry.ts +93 -22
- package/src/config.ts +12 -7
- package/src/index.ts +28 -0
- package/src/spoke/env-inject.ts +17 -2
- package/src/spoke/launchers.ts +145 -9
- package/src/spoke/spoke.ts +150 -10
- package/src/status.ts +70 -14
- package/src/win-acl.ts +154 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +1 -0
- package/web/dist/assets/{ArtifactPane-DPHjiWce.js → ArtifactPane-DKwbMTX9.js} +1 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +6 -0
- package/web/dist/assets/Library-D6-Go6Cu.js +2 -0
- package/web/dist/assets/{ProfilesDestination-GriuOpiZ.js → ProfilesDestination-BOML2Dgf.js} +1 -1
- package/web/dist/assets/{TeamsPage-C-5lYeeM.js → TeamsPage-CNBTE8gB.js} +1 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +2 -0
- package/web/dist/assets/Watchtower-RphjcL0_.css +1 -0
- package/web/dist/assets/hub-CrbItiwP.js +13 -0
- package/web/dist/assets/index-82Ce0IAf.css +1 -0
- package/web/dist/assets/index-D9SP3_0z.js +22 -0
- package/web/dist/assets/{live-CDWcyavI.js → live-BNJgy0vg.js} +3 -3
- package/web/dist/assets/{pane-layout-Csk-vPNe.js → pane-layout-EdR1kRrG.js} +1 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +1 -0
- package/web/dist/assets/{renderer2d-CNFdTDTr.js → renderer2d-CYag1CnR.js} +1 -1
- package/web/dist/assets/{renderer3d-C45jPvYX.js → renderer3d-BRB6rgDJ.js} +1 -1
- package/web/dist/assets/{safe-area-sA9a8vYJ.js → safe-area-fOy0AOuG.js} +1 -1
- package/web/dist/assets/{source-BFLJQ43_.js → source-Wbq35qGp.js} +1 -1
- package/web/dist/assets/{ui-Qo2Pd9-O.js → ui-j5FFk90B.js} +1 -1
- package/web/dist/index.html +6 -4
- package/web/dist/assets/ArtifactPage-CSotAWob.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-CkmWnP6L.js +0 -6
- package/web/dist/assets/Library-DTLEyekn.js +0 -2
- package/web/dist/assets/Watchtower-CTgqHk5g.css +0 -1
- package/web/dist/assets/Watchtower-DOlPsiIy.js +0 -2
- package/web/dist/assets/index-6mdQ7eUY.js +0 -34
- package/web/dist/assets/index-C5OmwA1y.css +0 -1
package/src/config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Per-machine spoke configuration, shared by every OpenCode process on the machine and edited
|
|
2
|
-
// from the /hub dialog. Holds the machine token, so it is written with mode 0600.
|
|
2
|
+
// from the /hub dialog. Holds the machine token, so it is written with mode 0600 (on Windows: an owner-only ACL).
|
|
3
3
|
import { randomBytes } from "node:crypto"
|
|
4
4
|
import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from "node:fs"
|
|
5
5
|
import { homedir } from "node:os"
|
|
@@ -7,6 +7,7 @@ import path from "node:path"
|
|
|
7
7
|
import { hubEnv, type Env } from "./env"
|
|
8
8
|
import { APP_DIR, configBase, legacyPaths, migrateFirst } from "./migrate"
|
|
9
9
|
import { isHubURL } from "./protocol"
|
|
10
|
+
import { defaultWinAcl, isWin32, writeOwnerOnlyFile, type SecretFileOptions } from "./win-acl"
|
|
10
11
|
|
|
11
12
|
export type Mode = "off" | "always" | "session"
|
|
12
13
|
|
|
@@ -169,8 +170,12 @@ export function readRawConfig(file: string): RawConfig | undefined {
|
|
|
169
170
|
return parsed
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Writes `content` to `file` atomically (a private temp file in the same directory, fsync, rename), mode 0600. On
|
|
175
|
+
* Windows the temporary file is restricted to the current user (an ACL) before the content is written (win-acl.ts).
|
|
176
|
+
*/
|
|
177
|
+
function writePrivate(file: string, content: string, options?: SecretFileOptions): void {
|
|
178
|
+
if (isWin32(options)) return writeOwnerOnlyFile(file, content, options?.acl ?? defaultWinAcl())
|
|
174
179
|
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
175
180
|
const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`
|
|
176
181
|
let fd: number | undefined
|
|
@@ -196,22 +201,22 @@ const serialize = (raw: RawConfig) => `${JSON.stringify(raw, null, 2)}\n`
|
|
|
196
201
|
* returns the next one, written atomically with 0600. Refuses (throws, file untouched) when the file is not a JSON
|
|
197
202
|
* object, so a hand-edited config is never replaced.
|
|
198
203
|
*/
|
|
199
|
-
export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig): void {
|
|
200
|
-
writePrivate(file, serialize(update(readRawConfig(file) ?? {})))
|
|
204
|
+
export function updateRawConfig(file: string, update: (raw: RawConfig) => RawConfig, options?: SecretFileOptions): void {
|
|
205
|
+
writePrivate(file, serialize(update(readRawConfig(file) ?? {})), options)
|
|
201
206
|
}
|
|
202
207
|
|
|
203
208
|
/**
|
|
204
209
|
* Atomic write with 0600 permissions. Lossless: keys config.json holds that VoiceConfig does not know are kept; the
|
|
205
210
|
* known keys come from `config` (a known key it lacks is removed). A file that is not a JSON object is replaced.
|
|
206
211
|
*/
|
|
207
|
-
export function writeConfig(file: string, config: VoiceConfig): void {
|
|
212
|
+
export function writeConfig(file: string, config: VoiceConfig, options?: SecretFileOptions): void {
|
|
208
213
|
let previous: RawConfig = {}
|
|
209
214
|
try {
|
|
210
215
|
previous = readRawConfig(file) ?? {}
|
|
211
216
|
} catch {}
|
|
212
217
|
const next: RawConfig = { ...previous }
|
|
213
218
|
for (const key of CONFIG_KEYS) delete next[key]
|
|
214
|
-
writePrivate(file, serialize({ ...next, ...config }))
|
|
219
|
+
writePrivate(file, serialize({ ...next, ...config }), options)
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
/** Changing the server URL drops the machine credentials, which belong to the old server. */
|
package/src/index.ts
CHANGED
|
@@ -162,6 +162,33 @@ async function activity(kind: "service" | "standalone", directories: readonly st
|
|
|
162
162
|
return { active: Object.keys(active ?? {}), jobs }
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* What OpenCode waits for in each session on this process's own server: its pending forms (session.form.list) and
|
|
167
|
+
* permission requests (session.permission.list). A session that cannot be read is left out; nothing when the
|
|
168
|
+
* server cannot be reached.
|
|
169
|
+
*/
|
|
170
|
+
async function pending(kind: "service" | "standalone", sessionIDs: readonly string[]) {
|
|
171
|
+
const client = await ownClient(kind).catch(() => undefined)
|
|
172
|
+
if (!client) return []
|
|
173
|
+
const out: Array<{ sessionID: string; forms: unknown[]; permissions: unknown[] }> = []
|
|
174
|
+
// One budget for the whole listing (the spoke gives up after 5 s too): a hung server stops being asked.
|
|
175
|
+
const signal = AbortSignal.timeout(PENDING_LIST_TIMEOUT_MS)
|
|
176
|
+
for (const sessionID of sessionIDs) {
|
|
177
|
+
if (signal.aborted) break
|
|
178
|
+
try {
|
|
179
|
+
const [forms, permissions] = await Promise.all([client.session.form.list({ sessionID }, { signal }), client.permission.list({ sessionID }, { signal })])
|
|
180
|
+
const list = (v: unknown) => unwrap<unknown[]>(v, Array.isArray)
|
|
181
|
+
out.push({ sessionID, forms: Array.isArray(list(forms)) ? list(forms) : [], permissions: Array.isArray(list(permissions)) ? list(permissions) : [] })
|
|
182
|
+
} catch {
|
|
183
|
+
// a session this server does not have (any more)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** How long one listing of pending forms and permissions may take on the own server. */
|
|
190
|
+
const PENDING_LIST_TIMEOUT_MS = 5000
|
|
191
|
+
|
|
165
192
|
/** Plugin API results may arrive bare or wrapped as { location, data }. */
|
|
166
193
|
function unwrap<T>(value: unknown, isBare: (v: any) => boolean): T {
|
|
167
194
|
const v = value as any
|
|
@@ -253,6 +280,7 @@ function hostFrom(ctx: Context, locations: () => PermissionLocation[], kind: "se
|
|
|
253
280
|
},
|
|
254
281
|
...(canList(kind) ? { listRecent: (options?: { days: number; max: number; include: readonly string[]; signal?: AbortSignal }) => listRecent(kind, () => locations().map((l) => l.directory), options) } : {}),
|
|
255
282
|
activity: (directories) => activity(kind, directories),
|
|
283
|
+
pending: (sessionIDs) => pending(kind, sessionIDs),
|
|
256
284
|
// Fork and revert: over the own server's API (the plugin API has neither); only where it can be reached.
|
|
257
285
|
...(canList(kind)
|
|
258
286
|
? {
|
package/src/spoke/env-inject.ts
CHANGED
|
@@ -114,13 +114,28 @@ export function stripInheritedHubEnv(env: Env): void {
|
|
|
114
114
|
for (const name of HUB_VARS) delete env[name]
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
118
|
-
|
|
117
|
+
/**
|
|
118
|
+
* Strips inherited HUB_*, sets the session's identity when it has one, and puts `binDir` first on PATH once. On
|
|
119
|
+
* win32 the variable is case-insensitive (usually `Path`) and ';'-separated: the one key keeps its name, entries
|
|
120
|
+
* compare without case, and no second `PATH` key is added (which one a child sees would be undefined).
|
|
121
|
+
*/
|
|
122
|
+
export function applyHubEnv(env: Env, identity: Identity | undefined, binDir: string, platform: string = process.platform): void {
|
|
119
123
|
stripInheritedHubEnv(env)
|
|
120
124
|
if (identity) {
|
|
121
125
|
if (identity.url) env.HUB_URL = identity.url
|
|
122
126
|
env.HUB_SESSION_TOKEN = identity.token
|
|
123
127
|
}
|
|
128
|
+
if (platform === "win32") {
|
|
129
|
+
const keys = Object.keys(env).filter((k) => k.toLowerCase() === "path")
|
|
130
|
+
const key = keys.find((k) => env[k] !== undefined) ?? keys[0] ?? "Path"
|
|
131
|
+
const value = env[key]
|
|
132
|
+
for (const k of keys) delete env[k]
|
|
133
|
+
const same = (dir: string) => dir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === binDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
|
|
134
|
+
const root = env.SystemRoot ?? env.SYSTEMROOT ?? "C:\\Windows"
|
|
135
|
+
const rest = value === undefined ? [`${root}\\System32`, root] : value.split(";").filter((dir) => dir && !same(dir))
|
|
136
|
+
env[key] = [binDir, ...rest].join(";")
|
|
137
|
+
return
|
|
138
|
+
}
|
|
124
139
|
const rest = env.PATH === undefined ? DEFAULT_PATH : env.PATH.split(path.delimiter).filter((dir) => dir !== binDir)
|
|
125
140
|
env.PATH = [binDir, ...rest].join(path.delimiter)
|
|
126
141
|
}
|
package/src/spoke/launchers.ts
CHANGED
|
@@ -13,8 +13,16 @@
|
|
|
13
13
|
// when nothing is there or it is hub's own link, and never when another `hub` is on PATH. ~/.local/bin/hubctl is left
|
|
14
14
|
// to `hubctl setup`: it is the standalone hubctl's install path (a link there would make that install refuse to write
|
|
15
15
|
// through it, and the launchers would exec themselves).
|
|
16
|
+
//
|
|
17
|
+
// On Windows (Phase 2) there are no sh scripts and no symlinks (they need Developer Mode or admin): the hub bin dir
|
|
18
|
+
// gets hub.cmd and hubctl.cmd, which run ~\.local\bin\hubctl.exe when it is installed (decided at plugin start)
|
|
19
|
+
// and the bundle otherwise, with BUN_BE_BUN set only inside the launcher (setlocal). ~\.local\bin\hub.cmd is a cmd
|
|
20
|
+
// shim (packages/cli/src/bootstrap/transaction/shim.ts) to the bin dir's hub.cmd, under the same user-bin rule;
|
|
21
|
+
// hubctl's own hub.cmd (a shim to hubctl.exe, link-hub) is the authority and is never touched. Older plugin versions
|
|
22
|
+
// left POSIX launchers and a ~/.local/bin/hub symlink there; being hub's own, they are removed.
|
|
16
23
|
import { accessSync, chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"
|
|
17
24
|
import path from "node:path"
|
|
25
|
+
import { cmdShim, cmdWord, parseCmdShim } from "../../packages/cli/src/bootstrap/transaction/shim.ts"
|
|
18
26
|
import { userBinLinkDecision, type UserBinEntry } from "../../packages/cli/src/setup/user-bin.ts"
|
|
19
27
|
|
|
20
28
|
export const LAUNCHERS = ["hub", "hubctl"] as const
|
|
@@ -31,19 +39,35 @@ export interface LauncherOptions {
|
|
|
31
39
|
readonly bun: boolean
|
|
32
40
|
/** The CLI entry the runtime runs. */
|
|
33
41
|
readonly entry: string
|
|
34
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* The standalone hubctl to prefer when it exists (standaloneHubctl(home)). POSIX: checked when the launcher runs;
|
|
44
|
+
* win32: installLaunchers passes it only when it exists, and launcherScript then runs it unconditionally.
|
|
45
|
+
*/
|
|
35
46
|
readonly standalone?: string
|
|
47
|
+
/** The platform (default process.platform): "win32" writes cmd launchers. */
|
|
48
|
+
readonly platform?: string
|
|
36
49
|
}
|
|
37
50
|
|
|
38
|
-
|
|
39
|
-
|
|
51
|
+
const isWin = (platform: string | undefined) => (platform ?? process.platform) === "win32"
|
|
52
|
+
|
|
53
|
+
/** Where `hubctl setup` installs the standalone hubctl (contract user-bin:hubctl, user-bin:hubctl.exe on Windows). */
|
|
54
|
+
export const standaloneHubctl = (home: string, platform: string = process.platform): string => path.join(home, ".local", "bin", platform === "win32" ? "hubctl.exe" : "hubctl")
|
|
40
55
|
|
|
41
56
|
/** A word quoted for sh. */
|
|
42
57
|
const quote = (word: string) => `'${word.replaceAll("'", `'\\''`)}'`
|
|
43
58
|
|
|
44
59
|
const LAUNCHER_HEADER = "#!/bin/sh\n# hub CLI launcher"
|
|
60
|
+
const CMD_HEADER = "@rem hub CLI launcher"
|
|
61
|
+
|
|
62
|
+
/** win32: a cmd file (CRLF, ASCII; every path one quoted word, see cmdWord). */
|
|
63
|
+
function cmdLauncher({ execPath, bun, entry, standalone }: LauncherOptions): string {
|
|
64
|
+
const lines = standalone ? [`@${cmdWord(standalone)} %*`] : bun ? ["@setlocal", "@set BUN_BE_BUN=1", `@${cmdWord(execPath)} ${cmdWord(entry)} %*`] : [`@${cmdWord(execPath)} ${cmdWord(entry)} %*`]
|
|
65
|
+
return [`${CMD_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).`, ...lines, ""].join("\r\n")
|
|
66
|
+
}
|
|
45
67
|
|
|
46
|
-
export function launcherScript(
|
|
68
|
+
export function launcherScript(options: LauncherOptions): string {
|
|
69
|
+
if (isWin(options.platform)) return cmdLauncher(options)
|
|
70
|
+
const { execPath, bun, entry, standalone } = options
|
|
47
71
|
const command = bun ? `exec env BUN_BE_BUN=1 ${quote(execPath)} ${quote(entry)} "$@"` : `exec node ${quote(entry)} "$@"`
|
|
48
72
|
const prefer = standalone ? `if [ -x ${quote(standalone)} ]; then exec ${quote(standalone)} "$@"; fi\n` : ""
|
|
49
73
|
return `${LAUNCHER_HEADER}, written by the hub OpenCode plugin at start (edits are overwritten).\n${prefer}${command}\n`
|
|
@@ -62,6 +86,7 @@ export function cliEntry(root: string, bun: boolean): string | undefined {
|
|
|
62
86
|
* reads half a file). Returns the names written.
|
|
63
87
|
*/
|
|
64
88
|
export function installLaunchers(options: LauncherOptions & { readonly binDir: string }): { written: string[] } {
|
|
89
|
+
if (isWin(options.platform)) return installCmdLaunchers(options)
|
|
65
90
|
const script = launcherScript(options)
|
|
66
91
|
mkdirSync(options.binDir, { recursive: true, mode: 0o700 })
|
|
67
92
|
const written: string[] = []
|
|
@@ -86,6 +111,34 @@ export function installLaunchers(options: LauncherOptions & { readonly binDir: s
|
|
|
86
111
|
return { written }
|
|
87
112
|
}
|
|
88
113
|
|
|
114
|
+
/** win32: hub.cmd and hubctl.cmd (no modes); an older plugin's POSIX launchers there are removed. */
|
|
115
|
+
function installCmdLaunchers(options: LauncherOptions & { readonly binDir: string }): { written: string[] } {
|
|
116
|
+
const { standalone, ...rest } = options
|
|
117
|
+
const script = launcherScript({ ...rest, ...(standalone && existsSync(standalone) ? { standalone } : {}) })
|
|
118
|
+
mkdirSync(options.binDir, { recursive: true, mode: 0o700 })
|
|
119
|
+
const written: string[] = []
|
|
120
|
+
for (const base of LAUNCHERS) {
|
|
121
|
+
const stale = path.join(options.binDir, base)
|
|
122
|
+
if (entryOf(stale) === "file" && isLauncher(stale)) rmSync(stale, { force: true })
|
|
123
|
+
const name = `${base}.cmd`
|
|
124
|
+
const file = path.join(options.binDir, name)
|
|
125
|
+
if (readText(file) === script) continue
|
|
126
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
127
|
+
writeFileSync(tmp, script)
|
|
128
|
+
renameSync(tmp, file)
|
|
129
|
+
written.push(name)
|
|
130
|
+
}
|
|
131
|
+
return { written }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const readText = (file: string): string | undefined => {
|
|
135
|
+
try {
|
|
136
|
+
return readFileSync(file, "latin1")
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
89
142
|
/** The user's own bin dir (contract root user-bin). */
|
|
90
143
|
export const userBinDir = (home: string): string => path.join(home, ".local", "bin")
|
|
91
144
|
|
|
@@ -98,13 +151,13 @@ function entryOf(file: string): UserBinEntry {
|
|
|
98
151
|
}
|
|
99
152
|
}
|
|
100
153
|
|
|
101
|
-
/** A file that starts like the launchers hub writes. */
|
|
102
|
-
function isLauncher(file: string): boolean {
|
|
154
|
+
/** A file that starts like the launchers hub writes (the cmd ones on win32). */
|
|
155
|
+
function isLauncher(file: string, header: string = LAUNCHER_HEADER): boolean {
|
|
103
156
|
let fd: number | undefined
|
|
104
157
|
try {
|
|
105
158
|
fd = openSync(file, "r")
|
|
106
|
-
const buffer = Buffer.alloc(
|
|
107
|
-
return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") ===
|
|
159
|
+
const buffer = Buffer.alloc(header.length)
|
|
160
|
+
return readSync(fd, buffer, 0, buffer.length, 0) === buffer.length && buffer.toString("utf8") === header
|
|
108
161
|
} catch {
|
|
109
162
|
return false
|
|
110
163
|
} finally {
|
|
@@ -126,17 +179,100 @@ function onPath(name: string, searchPath: string, skip: string): string | undefi
|
|
|
126
179
|
return undefined
|
|
127
180
|
}
|
|
128
181
|
|
|
182
|
+
/** win32: PATH entries compare without case, separator style or a trailing separator. */
|
|
183
|
+
const winKey = (dir: string) => dir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()
|
|
184
|
+
|
|
185
|
+
/** win32: the first `name` with a PATHEXT extension on the ';'-separated `searchPath`, outside `skip`. */
|
|
186
|
+
function onWinPath(name: string, searchPath: string, skip: string): string | undefined {
|
|
187
|
+
const exts = [".com", ".exe", ".bat", ".cmd"]
|
|
188
|
+
for (const dir of searchPath.split(";")) {
|
|
189
|
+
if (!dir || winKey(dir) === winKey(skip)) continue
|
|
190
|
+
for (const ext of exts) {
|
|
191
|
+
const file = path.join(dir, `${name}${ext}`)
|
|
192
|
+
try {
|
|
193
|
+
if (statSync(file).isFile()) return file
|
|
194
|
+
} catch {}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** win32: what a cmd file is, read as the user-bin rule sees links: a hub cmd shim is a link to its target. */
|
|
201
|
+
function cmdEntryOf(file: string): { entry: UserBinEntry; pointsTo?: string } {
|
|
202
|
+
const entry = entryOf(file)
|
|
203
|
+
if (entry !== "file") return { entry }
|
|
204
|
+
let bytes: Uint8Array
|
|
205
|
+
try {
|
|
206
|
+
bytes = readFileSync(file)
|
|
207
|
+
} catch {
|
|
208
|
+
return { entry }
|
|
209
|
+
}
|
|
210
|
+
const target = parseCmdShim(bytes)
|
|
211
|
+
return target === undefined ? { entry } : { entry: "symlink", pointsTo: target }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** win32: linkUserBin with cmd shims (see the header). */
|
|
215
|
+
function linkUserBinCmd(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined }): { linked: string[]; hint?: string } {
|
|
216
|
+
const userBin = userBinDir(options.home)
|
|
217
|
+
// An older plugin's symlink ~/.local/bin/hub → its POSIX launcher: hub's own, and unusable here.
|
|
218
|
+
const old = path.join(userBin, "hub")
|
|
219
|
+
if (entryOf(old) === "symlink") {
|
|
220
|
+
let to: string | undefined
|
|
221
|
+
try {
|
|
222
|
+
to = path.resolve(userBin, readlinkSync(old))
|
|
223
|
+
} catch {}
|
|
224
|
+
if (to !== undefined && (winKey(to) === winKey(path.join(options.binDir, "hub")) || isLauncher(to))) rmSync(old, { force: true })
|
|
225
|
+
}
|
|
226
|
+
const linkPath = path.join(userBin, "hub.cmd")
|
|
227
|
+
const target = path.join(options.binDir, "hub.cmd")
|
|
228
|
+
const { entry, pointsTo } = cmdEntryOf(linkPath)
|
|
229
|
+
const searchPath = options.path ?? ""
|
|
230
|
+
const onUserPath = searchPath.split(";").some((dir) => dir && winKey(dir) === winKey(userBin))
|
|
231
|
+
const pathHint = onUserPath ? undefined : `${userBin} is not on PATH: add it to your user PATH to use \`hub\` in your terminal`
|
|
232
|
+
// hubctl's link-hub shim to the standalone hubctl.exe: the authority, keep it.
|
|
233
|
+
if (pointsTo !== undefined && winKey(pointsTo) === winKey(standaloneHubctl(options.home, "win32"))) return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
|
|
234
|
+
const foundOnPath = onWinPath("hub", searchPath, options.binDir)
|
|
235
|
+
const decision = userBinLinkDecision({
|
|
236
|
+
entry,
|
|
237
|
+
...(pointsTo !== undefined ? { pointsTo: winKey(pointsTo) === winKey(target) ? target : pointsTo } : {}),
|
|
238
|
+
target,
|
|
239
|
+
linkPath,
|
|
240
|
+
hubOwned: (p) => isLauncher(p, CMD_HEADER),
|
|
241
|
+
...(foundOnPath !== undefined && winKey(foundOnPath) !== winKey(linkPath) ? { foundOnPath } : {}),
|
|
242
|
+
})
|
|
243
|
+
if (decision === "foreign") return { linked: [] }
|
|
244
|
+
if (decision === "shadowed") return { linked: [], hint: `\`hub\` on your PATH is ${foundOnPath}, not hub's CLI; hub's is ${target}` }
|
|
245
|
+
if (decision === "in-place") return { linked: [], ...(pathHint ? { hint: pathHint } : {}) }
|
|
246
|
+
mkdirSync(userBin, { recursive: true })
|
|
247
|
+
const shim = cmdShim(target)
|
|
248
|
+
// A new shim never replaces what may have appeared meanwhile ("wx"); hub's own is replaced atomically.
|
|
249
|
+
if (decision === "create") writeFileSync(linkPath, shim, { flag: "wx" })
|
|
250
|
+
else {
|
|
251
|
+
const tmp = `${linkPath}.${process.pid}.tmp`
|
|
252
|
+
rmSync(tmp, { force: true })
|
|
253
|
+
writeFileSync(tmp, shim)
|
|
254
|
+
try {
|
|
255
|
+
renameSync(tmp, linkPath)
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
rmSync(tmp, { force: true })
|
|
258
|
+
throw cause
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return { linked: ["hub.cmd"], ...(pathHint ? { hint: pathHint } : {}) }
|
|
262
|
+
}
|
|
263
|
+
|
|
129
264
|
/**
|
|
130
265
|
* Links ~/.local/bin/hub to `binDir`/hub (see the header). Returns what it linked, and one hint when the user's
|
|
131
266
|
* terminal will not find it (~/.local/bin not on `path`, or another `hub` comes first). Small synchronous calls,
|
|
132
267
|
* nothing when converged (the plugin runs this at start).
|
|
133
268
|
*/
|
|
134
|
-
export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined }): { linked: string[]; hint?: string } {
|
|
269
|
+
export function linkUserBin(options: { readonly home: string; readonly binDir: string; readonly path: string | undefined; readonly defaultBinDir?: string | undefined; readonly platform?: string }): { linked: string[]; hint?: string } {
|
|
135
270
|
// Only the default hub bin dir (env-inject.ts defaultHubBinDir: the resolved state dir's bin, default
|
|
136
271
|
// ~/.local/state/hub/bin): a custom HUB_STATE_DIR (tests, containers, a throwaway state) must never leave
|
|
137
272
|
// ~/.local/bin pointing into it.
|
|
138
273
|
const allowed = options.defaultBinDir ?? path.join(options.home, ".local", "state", "hub", "bin")
|
|
139
274
|
if (path.resolve(options.binDir) !== path.resolve(allowed)) return { linked: [] }
|
|
275
|
+
if (isWin(options.platform)) return linkUserBinCmd(options)
|
|
140
276
|
const userBin = userBinDir(options.home)
|
|
141
277
|
const linkPath = path.join(userBin, "hub")
|
|
142
278
|
const target = path.join(options.binDir, "hub")
|
package/src/spoke/spoke.ts
CHANGED
|
@@ -141,6 +141,12 @@ export interface SpokeHost {
|
|
|
141
141
|
* were already running when this process started, or while its event stream was down.
|
|
142
142
|
*/
|
|
143
143
|
activity?(directories: readonly string[]): Promise<{ readonly active: readonly string[]; readonly jobs: ReadonlyArray<JobInfo & { readonly sessionID: string }> }>
|
|
144
|
+
/**
|
|
145
|
+
* What OpenCode waits for in each of the sessions, from this process's own server: pending forms (FormInfo, as in
|
|
146
|
+
* form.created) and permission requests (as in permission.asked). A session it could not read is left out.
|
|
147
|
+
* Absent, or empty when the server cannot be reached: only what this process saw asked is re-announced.
|
|
148
|
+
*/
|
|
149
|
+
pending?(sessionIDs: readonly string[]): Promise<ReadonlyArray<PendingAsks>>
|
|
144
150
|
/**
|
|
145
151
|
* Recent root sessions with their workspace: those in the history window (`days`, at most `max`), plus `include`
|
|
146
152
|
* (always listed: open, running, ...). The background service lists every session; a foreground `opencode serve`
|
|
@@ -177,6 +183,13 @@ export interface SpokeHost {
|
|
|
177
183
|
revertCommit?(sessionID: string): Promise<unknown>
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
/** A session's pending forms and permission requests as OpenCode lists them (raw, see SpokeHost.pending). */
|
|
187
|
+
export interface PendingAsks {
|
|
188
|
+
readonly sessionID: string
|
|
189
|
+
readonly forms: readonly unknown[]
|
|
190
|
+
readonly permissions: readonly unknown[]
|
|
191
|
+
}
|
|
192
|
+
|
|
180
193
|
/** A form reply for OpenCode: an answer, or a cancel. */
|
|
181
194
|
export type FormAction =
|
|
182
195
|
| { readonly sessionID: string; readonly formID: string; readonly answer: FormAnswer }
|
|
@@ -205,6 +218,8 @@ export interface SpokeOptions {
|
|
|
205
218
|
readonly forkNoteTimeoutMs?: number
|
|
206
219
|
readonly snapshotIntervalMs?: number
|
|
207
220
|
readonly seedIntervalMs?: number
|
|
221
|
+
/** Longest the own server may take to list a seed's pending forms and permissions (default 5 s). */
|
|
222
|
+
readonly pendingTimeoutMs?: number
|
|
208
223
|
/** Delay before re-listing the service's sessions after a session event (default 2 s). */
|
|
209
224
|
readonly seedDebounceMs?: number
|
|
210
225
|
/** Longest one session listing may take before it is abandoned and later listings run (default 60 s). */
|
|
@@ -389,6 +404,15 @@ export class Spoke {
|
|
|
389
404
|
private started = false
|
|
390
405
|
/** A seedActivity call is in flight. */
|
|
391
406
|
private seeding = false
|
|
407
|
+
/**
|
|
408
|
+
* Pending permissions and forms (`per <id>` / `frm <id>`) with the event that asked them, as forwarded: the hub
|
|
409
|
+
* keeps them only as long as this connection, so they are announced again after a reconnect (see reannounce).
|
|
410
|
+
* `seq` orders them against a pending listing (one asked while OpenCode was being asked is never closed by it).
|
|
411
|
+
*/
|
|
412
|
+
private readonly asks = new Map<string, { sessionID: string; event: { type: string; data: Record<string, unknown> }; seq: number }>()
|
|
413
|
+
private askSeq = 0
|
|
414
|
+
/** Asks seen answered or cancelled (an ID never comes back): a listing taken before the reply cannot revive them. */
|
|
415
|
+
private readonly closedAsks = new Set<string>()
|
|
392
416
|
/** Sessions shown by OpenCode TUIs (voice.presence). */
|
|
393
417
|
private readonly presenceBook = new PresenceBook()
|
|
394
418
|
/** Form replies waiting for a TUI to carry them out (when the plugin cannot reach its own server). */
|
|
@@ -907,6 +931,9 @@ export class Spoke {
|
|
|
907
931
|
onWelcome: () => {
|
|
908
932
|
this.sendSnapshot()
|
|
909
933
|
this.sendTerminals(true)
|
|
934
|
+
// A new connection: the hub dropped what was pending with the old one (or restarted). What this process
|
|
935
|
+
// remembers goes out again at once; the seed then asks OpenCode what it still waits for.
|
|
936
|
+
this.reannounce()
|
|
910
937
|
void this.seedActivity()
|
|
911
938
|
},
|
|
912
939
|
onState: (state, error) => {
|
|
@@ -1784,6 +1811,7 @@ export class Spoke {
|
|
|
1784
1811
|
const title = (event.data as Record<string, unknown>).title
|
|
1785
1812
|
if (typeof title === "string") for (const waiter of this.renameWaiters.get(sessionID) ?? []) waiter(title)
|
|
1786
1813
|
}
|
|
1814
|
+
this.trackAsk(event)
|
|
1787
1815
|
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
1788
1816
|
if (REVERT_EVENTS.has(event.type) && typeof sessionID === "string") void this.observeRevert(event, sessionID)
|
|
1789
1817
|
if (typeof sessionID === "string") this.profiles.settle(sessionID) // a turn boundary makes a staged profile effective
|
|
@@ -1791,21 +1819,123 @@ export class Spoke {
|
|
|
1791
1819
|
this.scheduleSeed()
|
|
1792
1820
|
}
|
|
1793
1821
|
void this.fetchInfo()
|
|
1794
|
-
|
|
1795
|
-
// Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
|
|
1796
|
-
// get their session from the job.
|
|
1797
|
-
const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
|
|
1798
|
-
if (compact && shouldForward(this.config, this.tracker, compact)) {
|
|
1799
|
-
this.announce(compact)
|
|
1800
|
-
this.connection.send({ t: "event", event: compact })
|
|
1801
|
-
if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1822
|
+
this.forward(event)
|
|
1804
1823
|
} catch (cause) {
|
|
1805
1824
|
console.error("[hub] event handling failed", cause)
|
|
1806
1825
|
}
|
|
1807
1826
|
}
|
|
1808
1827
|
|
|
1828
|
+
/** Sends an OpenCode event to the hub (compact), when connected and the hub may see its session. */
|
|
1829
|
+
private forward(event: EventLike): void {
|
|
1830
|
+
if (this.connection?.state !== "connected" || (!FORWARDED_EVENTS.has(event.type) && !COMPACTED_EVENTS.has(event.type))) return
|
|
1831
|
+
// Activity events go out compact: tool inputs become a short summary, outputs are dropped; job exits
|
|
1832
|
+
// get their session from the job.
|
|
1833
|
+
const compact = this.withOrigin(compactActivity({ type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, (id) => this.tracker.jobSession(id)))
|
|
1834
|
+
if (!compact || !shouldForward(this.config, this.tracker, compact)) return
|
|
1835
|
+
this.announce(compact)
|
|
1836
|
+
this.connection.send({ t: "event", event: compact })
|
|
1837
|
+
const sessionID = (event.data as Record<string, unknown> | undefined)?.sessionID
|
|
1838
|
+
if (compact.type === "session.execution.failed" && typeof sessionID === "string" && !this.announced.has(sessionID)) this.unheardFailures.add(sessionID)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
/** Keeps `asks` current: a permission or form asked, replied, cancelled, or its session deleted. */
|
|
1842
|
+
private trackAsk(event: EventLike): void {
|
|
1843
|
+
const data = (event.data ?? {}) as Record<string, any>
|
|
1844
|
+
if (event.type === "permission.asked" && typeof data.id === "string" && typeof data.sessionID === "string") {
|
|
1845
|
+
this.rememberAsk(`per ${data.id}`, data.sessionID, event)
|
|
1846
|
+
} else if (event.type === "form.created" && typeof data.form?.id === "string" && typeof data.form?.sessionID === "string") {
|
|
1847
|
+
this.rememberAsk(`frm ${data.form.id}`, data.form.sessionID, event)
|
|
1848
|
+
} else if (event.type === "permission.replied" && typeof data.requestID === "string") this.closeAsk(`per ${data.requestID}`)
|
|
1849
|
+
else if ((event.type === "form.replied" || event.type === "form.cancelled") && typeof data.id === "string") this.closeAsk(`frm ${data.id}`)
|
|
1850
|
+
else if (event.type === "session.deleted" && typeof data.sessionID === "string") {
|
|
1851
|
+
for (const [key, ask] of this.asks) if (ask.sessionID === data.sessionID) this.asks.delete(key)
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
private closeAsk(key: string): void {
|
|
1856
|
+
this.asks.delete(key)
|
|
1857
|
+
this.closedAsks.add(key)
|
|
1858
|
+
if (this.closedAsks.size > ASKS_CLOSED_KEPT) this.closedAsks.delete(this.closedAsks.values().next().value!)
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
private rememberAsk(key: string, sessionID: string, event: EventLike): void {
|
|
1862
|
+
if (this.closedAsks.has(key)) return
|
|
1863
|
+
this.asks.delete(key)
|
|
1864
|
+
this.asks.set(key, { sessionID, event: { type: event.type, data: (event.data ?? {}) as Record<string, unknown> }, seq: ++this.askSeq })
|
|
1865
|
+
if (this.asks.size > ASKS_KEPT) this.asks.delete(this.asks.keys().next().value!)
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Announces every pending permission and form again (after a reconnect: the hub dropped them with the old
|
|
1870
|
+
* connection). A hub without "pendingReplay" would notify a re-sent permission again: it gets the forms only.
|
|
1871
|
+
*/
|
|
1872
|
+
private reannounce(): void {
|
|
1873
|
+
const permissions = this.hubSupports("pendingReplay")
|
|
1874
|
+
for (const ask of [...this.asks.values()]) if (permissions || ask.event.type !== "permission.asked") this.forward(ask.event)
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Asks OpenCode what the sessions wait for (the active ones, and those with something pending here): forms and
|
|
1879
|
+
* permissions this process never saw asked (it started afterwards, or its stream was down) are announced; those
|
|
1880
|
+
* it remembers that OpenCode no longer lists (answered while the stream was down) are closed on the hub.
|
|
1881
|
+
*/
|
|
1882
|
+
private async reconcileAsks(active: readonly string[]): Promise<void> {
|
|
1883
|
+
const pending = this.host.pending
|
|
1884
|
+
if (!pending) return
|
|
1885
|
+
// Sessions with something remembered first: a reply missed while the stream was down is only seen by asking.
|
|
1886
|
+
const ids = [...new Set([...[...this.asks.values()].map((a) => a.sessionID).reverse(), ...active])].slice(0, PENDING_SESSIONS_MAX)
|
|
1887
|
+
if (!ids.length) return
|
|
1888
|
+
const asked = this.askSeq
|
|
1889
|
+
const limit = this.options.pendingTimeoutMs ?? PENDING_TIMEOUT_MS
|
|
1890
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
1891
|
+
let lists: ReadonlyArray<PendingAsks>
|
|
1892
|
+
try {
|
|
1893
|
+
const listed = Promise.resolve(pending.call(this.host, ids))
|
|
1894
|
+
listed.catch(() => {}) // a listing that fails after the timeout is not an unhandled rejection
|
|
1895
|
+
const timeout = new Promise<never>((_, reject) => (timer = setTimeout(() => reject(new Error(`no answer within ${limit} ms`)), limit)))
|
|
1896
|
+
lists = await Promise.race([listed, timeout])
|
|
1897
|
+
} catch (cause) {
|
|
1898
|
+
console.error("[hub] could not read pending questions and permissions", errorText(cause))
|
|
1899
|
+
return
|
|
1900
|
+
} finally {
|
|
1901
|
+
clearTimeout(timer)
|
|
1902
|
+
}
|
|
1903
|
+
for (const list of lists) {
|
|
1904
|
+
if (!ids.includes(list.sessionID)) continue
|
|
1905
|
+
const listed = new Set<string>()
|
|
1906
|
+
const found: EventLike[] = []
|
|
1907
|
+
for (const form of Array.isArray(list.forms) ? list.forms : []) {
|
|
1908
|
+
const f = form as Record<string, unknown> | null
|
|
1909
|
+
if (typeof f?.id !== "string" || f.sessionID !== list.sessionID) continue
|
|
1910
|
+
listed.add(`frm ${f.id}`)
|
|
1911
|
+
if (!this.asks.has(`frm ${f.id}`) && !this.closedAsks.has(`frm ${f.id}`)) found.push({ type: "form.created", data: { form: f } })
|
|
1912
|
+
}
|
|
1913
|
+
for (const request of Array.isArray(list.permissions) ? list.permissions : []) {
|
|
1914
|
+
const r = request as Record<string, unknown> | null
|
|
1915
|
+
if (typeof r?.id !== "string" || r.sessionID !== list.sessionID) continue
|
|
1916
|
+
listed.add(`per ${r.id}`)
|
|
1917
|
+
if (!this.asks.has(`per ${r.id}`) && !this.closedAsks.has(`per ${r.id}`)) found.push({ type: "permission.asked", data: r })
|
|
1918
|
+
}
|
|
1919
|
+
const gone: EventLike[] = []
|
|
1920
|
+
for (const [key, ask] of this.asks) {
|
|
1921
|
+
if (ask.sessionID !== list.sessionID || ask.seq > asked || listed.has(key)) continue
|
|
1922
|
+
const id = key.slice(4)
|
|
1923
|
+
// unlisted: the hub drops only this process's hold on it (another process may still hold it).
|
|
1924
|
+
gone.push(
|
|
1925
|
+
key.startsWith("per ")
|
|
1926
|
+
? { type: "permission.replied", data: { sessionID: ask.sessionID, requestID: id, unlisted: true } }
|
|
1927
|
+
: { type: "form.cancelled", data: { sessionID: ask.sessionID, id, unlisted: true } },
|
|
1928
|
+
)
|
|
1929
|
+
}
|
|
1930
|
+
for (const event of [...found, ...gone]) {
|
|
1931
|
+
this.trackAsk(event)
|
|
1932
|
+
this.tasks.observe(event, Date.now())
|
|
1933
|
+
if (this.tracker.observe(event, Date.now())) this.scheduleSnapshot()
|
|
1934
|
+
this.forward(event)
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1809
1939
|
/**
|
|
1810
1940
|
* Adds who put the item in the inbox (user or hub) to session.inbox.delivered, for hubs that route messages
|
|
1811
1941
|
* (they reset reply-chain hops on a user's turn); when unknown, the event goes out unchanged.
|
|
@@ -1878,6 +2008,7 @@ export class Spoke {
|
|
|
1878
2008
|
const event = { type: "session.seed", data: { sessionID: id, status, jobs, ...(error ? { error } : {}) } }
|
|
1879
2009
|
if (this.connection?.state === "connected" && shouldForward(this.config, this.tracker, event)) this.connection.send({ t: "event", event })
|
|
1880
2010
|
}
|
|
2011
|
+
await this.reconcileAsks([...active])
|
|
1881
2012
|
void this.fetchInfo()
|
|
1882
2013
|
this.scheduleSnapshot()
|
|
1883
2014
|
} catch (cause) {
|
|
@@ -2030,6 +2161,15 @@ const LISTING_TIMEOUT_MS = 60_000
|
|
|
2030
2161
|
/** Directories whose shells are listed when seeding. */
|
|
2031
2162
|
const SEED_DIRECTORIES = 20
|
|
2032
2163
|
|
|
2164
|
+
/** Pending permissions and forms remembered to announce again (oldest dropped). */
|
|
2165
|
+
const ASKS_KEPT = 200
|
|
2166
|
+
/** Answered or cancelled asks remembered (oldest dropped). */
|
|
2167
|
+
const ASKS_CLOSED_KEPT = 1000
|
|
2168
|
+
/** Sessions whose pending forms and permissions are read from OpenCode in one seed. */
|
|
2169
|
+
const PENDING_SESSIONS_MAX = 50
|
|
2170
|
+
/** Longest the own server may take to list them (SpokeOptions.pendingTimeoutMs). */
|
|
2171
|
+
const PENDING_TIMEOUT_MS = 5000
|
|
2172
|
+
|
|
2033
2173
|
/** Keeps the terminals frame within the protocol's limits. */
|
|
2034
2174
|
function limitWorkspaces(workspaces: readonly HerdrWorkspace[]): HerdrWorkspace[] {
|
|
2035
2175
|
return workspaces.slice(0, 200).map((w) => ({ id: w.id, label: w.label, directories: w.directories.slice(0, 20) }))
|