dsh-codespace-workspace 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +680 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +10 -0
- package/lib/client.js +3598 -0
- package/lib/host/codespaces.js +942 -0
- package/lib/host/gh.js +271 -0
- package/lib/host/github.js +619 -0
- package/lib/host/remote-tools.js +565 -0
- package/lib/host/routes.js +555 -0
- package/lib/host/session.js +267 -0
- package/lib/host/transport.js +463 -0
- package/lib/index.js +170 -0
- package/lib/shared.js +181 -0
- package/locale/en.json +122 -0
- package/locale/zh.json +122 -0
- package/package.json +80 -4
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session lifecycle wiring for dsh-codespace-workspace.
|
|
3
|
+
*
|
|
4
|
+
* Wires four things, all as effects with idempotent cleanups:
|
|
5
|
+
*
|
|
6
|
+
* 1. `agent/created` → per-agent remote tool surface for Codespace sessions.
|
|
7
|
+
* 2. The system-prompt rule, registered on the *agent's* scope so only a
|
|
8
|
+
* Codespace agent receives it.
|
|
9
|
+
* 3. `session/event` → `turn/end` arms the auto-pause countdown and
|
|
10
|
+
* `user/message` cancels it.
|
|
11
|
+
* 4. The shutdown stop-all, in the one place the harness guarantees to await.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-codespace-workspace/host/session
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { PROMPT_ORDER, PROMPT_SECTION, PROMPT_TEXT, redact } from '../shared.js'
|
|
17
|
+
import { ghAvailable, ghInfo } from './gh.js'
|
|
18
|
+
import { installRemoteTools } from './remote-tools.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Local tools hidden from a Codespace agent.
|
|
22
|
+
*
|
|
23
|
+
* They resolve services from their own host-plane context, so they would act
|
|
24
|
+
* on the *local* machine; the remote surface shadows the ones that exist and
|
|
25
|
+
* these are removed so a model cannot reach the local implementation by
|
|
26
|
+
* accident. `pwsh` is denied outright because a Codespace is Linux.
|
|
27
|
+
*
|
|
28
|
+
* VERIFY: this list is the union of the tool names the shipped presets mount
|
|
29
|
+
* (`dsh-tool-fs`, `dsh-tool-fs-search`, `dsh-tool-bash`, `dsh-tool-pwsh`).
|
|
30
|
+
* Tolerant branch: each name is checked against the live global view before it
|
|
31
|
+
* is denied, because `tools.restrict()` throws on a name that is registered
|
|
32
|
+
* nowhere — so a composition that mounts a different subset is handled without
|
|
33
|
+
* a special case.
|
|
34
|
+
*/
|
|
35
|
+
const LOCAL_TOOL_NAMES = ['read', 'write', 'edit', 'glob', 'grep', 'bash', 'pwsh', 'read_image']
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the transport target for one managed workspace.
|
|
39
|
+
*
|
|
40
|
+
* Prefers `gh codespace ssh` when the CLI is usable, and otherwise falls back
|
|
41
|
+
* to the ssh-config alias from the settings — which is the path that actually
|
|
42
|
+
* runs on a machine without `gh`.
|
|
43
|
+
*
|
|
44
|
+
* @param {{gh: object}} status - a {@link ghInfo}-derived status.
|
|
45
|
+
* @param {object} settings - resolved plugin settings.
|
|
46
|
+
* @param {object} record - the managed workspace record.
|
|
47
|
+
* @returns {{kind: 'gh'|'ssh', codespace?: string, alias?: string, cwd: string}} the target.
|
|
48
|
+
*/
|
|
49
|
+
export function buildTarget(status, settings, record) {
|
|
50
|
+
const cwd = record?.path ?? '/'
|
|
51
|
+
if (ghAvailable(status.gh)) return { kind: 'gh', codespace: record.name, cwd }
|
|
52
|
+
// VERIFY: the settings field is named `sshKeyPath` in the frozen settings
|
|
53
|
+
// schema while the data plane needs an ssh-config *alias*. It is read as the
|
|
54
|
+
// alias, which is what the contract's "ssh-config alias the user configured
|
|
55
|
+
// (settings field)" describes. Tolerant branch: an unusable value produces
|
|
56
|
+
// the transport's own clear message ("no SSH host alias is configured …")
|
|
57
|
+
// rather than a crash, and `gh` is preferred whenever it is available.
|
|
58
|
+
const alias = typeof settings?.sshKeyPath === 'string' ? settings.sshKeyPath.trim() : ''
|
|
59
|
+
return { kind: 'ssh', alias, cwd }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Wire every session-level behaviour.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} ctx - the plugin context.
|
|
66
|
+
* @param {object} manager - the {@link import('./codespaces.js').CodespaceManager}.
|
|
67
|
+
* @param {{getSettings?: () => object, run?: Function}} [options] - wiring options.
|
|
68
|
+
* @returns {() => void} a disposer that removes every registration. Idempotent.
|
|
69
|
+
*/
|
|
70
|
+
export function installSessionWiring(ctx, manager, options = {}) {
|
|
71
|
+
const getSettings = typeof options.getSettings === 'function' ? options.getSettings : () => ({})
|
|
72
|
+
const run = options.run
|
|
73
|
+
|
|
74
|
+
/** @type {Map<string, () => void>} per-agent disposers, keyed by agent id. */
|
|
75
|
+
const agentDisposers = new Map()
|
|
76
|
+
let disposed = false
|
|
77
|
+
|
|
78
|
+
const log = (level, message) => {
|
|
79
|
+
try {
|
|
80
|
+
const logger = ctx.logger
|
|
81
|
+
const fn = logger?.[level] ?? logger?.info
|
|
82
|
+
if (typeof fn === 'function') fn.call(logger, redact(`codespace: ${message}`))
|
|
83
|
+
} catch {
|
|
84
|
+
/* a logger is a nicety, never a dependency */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ------------------------------------------------------- prompt + per-agent
|
|
89
|
+
|
|
90
|
+
const promptEffect = ctx.effect(() => () => {
|
|
91
|
+
for (const disposer of agentDisposers.values()) {
|
|
92
|
+
try {
|
|
93
|
+
disposer()
|
|
94
|
+
} catch {
|
|
95
|
+
/* idempotent teardown */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
agentDisposers.clear()
|
|
99
|
+
}, 'dsh-codespace-workspace: per-agent cleanups')
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Set up one agent, when its session cwd is one of our placeholders.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} agent - the live agent.
|
|
105
|
+
*/
|
|
106
|
+
const onAgentCreated = async (payload) => {
|
|
107
|
+
if (disposed) return
|
|
108
|
+
const agent = payload?.agent ?? payload
|
|
109
|
+
try {
|
|
110
|
+
const cwd = agent?.session?.header?.cwd
|
|
111
|
+
const workspaceId = manager.isCodespacePath(cwd)
|
|
112
|
+
if (workspaceId === undefined) return
|
|
113
|
+
|
|
114
|
+
const record = manager.recordOf(workspaceId)
|
|
115
|
+
if (record === undefined) {
|
|
116
|
+
// A placeholder directory with no managed record. The session is still
|
|
117
|
+
// remote-shaped, but without a Codespace name there is nothing to
|
|
118
|
+
// connect to, so the surface is left alone rather than half-installed.
|
|
119
|
+
log('warn', `session cwd ${cwd} is a placeholder but no Codespace is recorded for it`)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const settings = getSettings()
|
|
124
|
+
let status
|
|
125
|
+
try {
|
|
126
|
+
status = { gh: await ghInfo(run) }
|
|
127
|
+
} catch {
|
|
128
|
+
status = { gh: { installed: false, version: '', authenticated: false, login: '' } }
|
|
129
|
+
}
|
|
130
|
+
const target = buildTarget(status, settings, record)
|
|
131
|
+
|
|
132
|
+
// One agent.ctx.effect owns the whole surface; the returned disposer is
|
|
133
|
+
// also keyed here, because unloading this plugin does not dispose
|
|
134
|
+
// agent.ctx registrations by itself.
|
|
135
|
+
const disposeTools = installRemoteTools(agent, {
|
|
136
|
+
manager,
|
|
137
|
+
workspaceId,
|
|
138
|
+
target,
|
|
139
|
+
localToolNames: LOCAL_TOOL_NAMES,
|
|
140
|
+
run,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// The prompt rule is registered on the agent's scope, so a local session
|
|
144
|
+
// never sees it. It is a second effect for the same reason the tools are:
|
|
145
|
+
// both must disappear together.
|
|
146
|
+
let disposePrompt = () => {}
|
|
147
|
+
try {
|
|
148
|
+
disposePrompt = agent.ctx.effect(
|
|
149
|
+
() => agent.ctx.systemPrompt.section({
|
|
150
|
+
name: PROMPT_SECTION,
|
|
151
|
+
order: PROMPT_ORDER,
|
|
152
|
+
text: PROMPT_TEXT,
|
|
153
|
+
}),
|
|
154
|
+
'dsh-codespace-workspace: codespace prompt rule',
|
|
155
|
+
)
|
|
156
|
+
} catch (error) {
|
|
157
|
+
log('warn', `cannot register the Codespace prompt rule: ${error instanceof Error ? error.message : String(error)}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let done = false
|
|
161
|
+
const dispose = () => {
|
|
162
|
+
if (done) return
|
|
163
|
+
done = true
|
|
164
|
+
try {
|
|
165
|
+
disposePrompt()
|
|
166
|
+
} catch {
|
|
167
|
+
/* idempotent teardown */
|
|
168
|
+
}
|
|
169
|
+
disposeTools()
|
|
170
|
+
}
|
|
171
|
+
agentDisposers.set(String(agent.id), dispose)
|
|
172
|
+
log('info', `agent ${agent.id} is a Codespace session for ${record.name} (transport ${target.kind})`)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
// A serial `agent/created` listener that throws rejects the announcement
|
|
175
|
+
// and breaks agent creation. Nothing here is worth that.
|
|
176
|
+
log('warn', `agent/created wiring failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const createdEffect = ctx.on('agent/created', onAgentCreated)
|
|
181
|
+
|
|
182
|
+
const disposedEffect = ctx.on('agent/disposed', (payload) => {
|
|
183
|
+
const agent = payload?.agent ?? payload
|
|
184
|
+
const id = agent?.id
|
|
185
|
+
if (id === undefined) return
|
|
186
|
+
const dispose = agentDisposers.get(String(id))
|
|
187
|
+
if (dispose === undefined) return
|
|
188
|
+
agentDisposers.delete(String(id))
|
|
189
|
+
dispose()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
// ------------------------------------------------------------- auto-pause
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Map a session to the workspace it belongs to, when it is a Codespace one.
|
|
196
|
+
*
|
|
197
|
+
* @param {object} session - the session that emitted the event.
|
|
198
|
+
* @returns {string|undefined} the workspace id.
|
|
199
|
+
*/
|
|
200
|
+
const workspaceOf = (session) => {
|
|
201
|
+
const cwd = session?.header?.cwd
|
|
202
|
+
return manager.isCodespacePath(cwd)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const eventEffect = ctx.on('session/event', (session, event) => {
|
|
206
|
+
if (disposed) return
|
|
207
|
+
try {
|
|
208
|
+
const type = event?.type
|
|
209
|
+
if (type !== 'turn/end' && type !== 'user/message') return
|
|
210
|
+
const workspaceId = workspaceOf(session)
|
|
211
|
+
if (workspaceId === undefined) return
|
|
212
|
+
|
|
213
|
+
if (type === 'turn/end') {
|
|
214
|
+
manager.setBusy(workspaceId, false)
|
|
215
|
+
manager.armAutoPause(workspaceId)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
// Any user message means the user is present: cancel the countdown and
|
|
219
|
+
// mark the workspace busy so the sidebar shows it as active.
|
|
220
|
+
manager.cancelAutoPause(workspaceId)
|
|
221
|
+
manager.setBusy(workspaceId, true)
|
|
222
|
+
} catch (error) {
|
|
223
|
+
log('warn', `session/event handling failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
// ---------------------------------------------------------------- shutdown
|
|
228
|
+
|
|
229
|
+
// The harness's only guaranteed teardown hook is this effect's own cleanup:
|
|
230
|
+
// `runProfile`'s dispose awaits `fiber.dispose()`, which awaits every effect
|
|
231
|
+
// disposer on the root fiber, including an async one. There is no
|
|
232
|
+
// `app-boot/shutdown` event and no Electron `before-quit` on the Host side.
|
|
233
|
+
//
|
|
234
|
+
// The budget is the harness's: 5s before `process.exit()`, and only 2s on
|
|
235
|
+
// the crash path. `stopAll` is latched, issues every stop concurrently, and
|
|
236
|
+
// never rejects — a rejected disposer surfaces as an AggregateError out of
|
|
237
|
+
// `runProfile`.
|
|
238
|
+
const shutdownEffect = ctx.effect(() => () => {
|
|
239
|
+
disposed = true
|
|
240
|
+
manager.dispose()
|
|
241
|
+
return manager.stopAll('shutdown').catch((error) => {
|
|
242
|
+
log('warn', `shutdown stop failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
243
|
+
})
|
|
244
|
+
}, 'dsh-codespace-workspace: stop managed Codespaces on shutdown')
|
|
245
|
+
|
|
246
|
+
let torn = false
|
|
247
|
+
return () => {
|
|
248
|
+
if (torn) return
|
|
249
|
+
torn = true
|
|
250
|
+
disposed = true
|
|
251
|
+
for (const dispose of [createdEffect, disposedEffect, eventEffect, promptEffect, shutdownEffect]) {
|
|
252
|
+
try {
|
|
253
|
+
if (typeof dispose === 'function') dispose()
|
|
254
|
+
} catch {
|
|
255
|
+
/* idempotent teardown */
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const dispose of agentDisposers.values()) {
|
|
259
|
+
try {
|
|
260
|
+
dispose()
|
|
261
|
+
} catch {
|
|
262
|
+
/* idempotent teardown */
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
agentDisposers.clear()
|
|
266
|
+
}
|
|
267
|
+
}
|