dsh-codespace-workspace 0.0.0-stage → 0.1.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.
@@ -0,0 +1,301 @@
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
+ // ------------------------------------------------- make the machine ready
193
+ //
194
+ // A Codespace that has auto-paused must be running before the model asks for
195
+ // anything, or every remote tool call in the turn fails with a connection
196
+ // error. `agent/pre-step` is the right place: the agent loop **awaits** this
197
+ // waterfall before it requests a step (verified in `dsh-agent-loop/lib/index.js`
198
+ // ≈L911), so returning late genuinely delays the turn instead of racing it. The
199
+ // archived-session gate in `dsh-api-session-controller` uses the same hook — to
200
+ // BLOCK a step; blocking is not what we want, because the user asked for the
201
+ // message to WAIT.
202
+ //
203
+ // Local sessions are untouched: the cwd check comes first and is cheap. The
204
+ // whole body is caught, because a throwing listener here would break the turn
205
+ // rather than delay it, and `next()` is always called so the decision chain
206
+ // still runs.
207
+ const preStepEffect = ctx.on('agent/pre-step', async (payload, next) => {
208
+ if (disposed) return next()
209
+ try {
210
+ const agent = payload?.agent
211
+ const workspaceId = manager.isCodespacePath(agent?.session?.header?.cwd)
212
+ if (workspaceId !== undefined) {
213
+ const outcome = await manager.ensureRunning(workspaceId, { signal: payload?.signal })
214
+ if (outcome.ok !== true) {
215
+ // Not fatal: the turn proceeds, and the model sees the real transport
216
+ // error rather than a turn that silently never starts.
217
+ log('warn', `a turn is starting while its Codespace is not usable (${outcome.reason}${outcome.state === undefined ? '' : `: ${outcome.state}`})`)
218
+ }
219
+ }
220
+ } catch (error) {
221
+ log('warn', `pre-step readiness check failed: ${error instanceof Error ? error.message : String(error)}`)
222
+ }
223
+ return next()
224
+ })
225
+
226
+ // ------------------------------------------------------------- auto-pause
227
+
228
+ /**
229
+ * Map a session to the workspace it belongs to, when it is a Codespace one.
230
+ *
231
+ * @param {object} session - the session that emitted the event.
232
+ * @returns {string|undefined} the workspace id.
233
+ */
234
+ const workspaceOf = (session) => {
235
+ const cwd = session?.header?.cwd
236
+ return manager.isCodespacePath(cwd)
237
+ }
238
+
239
+ const eventEffect = ctx.on('session/event', (session, event) => {
240
+ if (disposed) return
241
+ try {
242
+ const type = event?.type
243
+ if (type !== 'turn/end' && type !== 'user/message') return
244
+ const workspaceId = workspaceOf(session)
245
+ if (workspaceId === undefined) return
246
+
247
+ if (type === 'turn/end') {
248
+ manager.setBusy(workspaceId, false)
249
+ manager.armAutoPause(workspaceId)
250
+ return
251
+ }
252
+ // Any user message means the user is present: cancel the countdown and
253
+ // mark the workspace busy so the sidebar shows it as active.
254
+ manager.cancelAutoPause(workspaceId)
255
+ manager.setBusy(workspaceId, true)
256
+ } catch (error) {
257
+ log('warn', `session/event handling failed: ${error instanceof Error ? error.message : String(error)}`)
258
+ }
259
+ })
260
+
261
+ // ---------------------------------------------------------------- shutdown
262
+
263
+ // The harness's only guaranteed teardown hook is this effect's own cleanup:
264
+ // `runProfile`'s dispose awaits `fiber.dispose()`, which awaits every effect
265
+ // disposer on the root fiber, including an async one. There is no
266
+ // `app-boot/shutdown` event and no Electron `before-quit` on the Host side.
267
+ //
268
+ // The budget is the harness's: 5s before `process.exit()`, and only 2s on
269
+ // the crash path. `stopAll` is latched, issues every stop concurrently, and
270
+ // never rejects — a rejected disposer surfaces as an AggregateError out of
271
+ // `runProfile`.
272
+ const shutdownEffect = ctx.effect(() => () => {
273
+ disposed = true
274
+ manager.dispose()
275
+ return manager.stopAll('shutdown').catch((error) => {
276
+ log('warn', `shutdown stop failed: ${error instanceof Error ? error.message : String(error)}`)
277
+ })
278
+ }, 'dsh-codespace-workspace: stop managed Codespaces on shutdown')
279
+
280
+ let torn = false
281
+ return () => {
282
+ if (torn) return
283
+ torn = true
284
+ disposed = true
285
+ for (const dispose of [createdEffect, disposedEffect, eventEffect, preStepEffect, promptEffect, shutdownEffect]) {
286
+ try {
287
+ if (typeof dispose === 'function') dispose()
288
+ } catch {
289
+ /* idempotent teardown */
290
+ }
291
+ }
292
+ for (const dispose of agentDisposers.values()) {
293
+ try {
294
+ dispose()
295
+ } catch {
296
+ /* idempotent teardown */
297
+ }
298
+ }
299
+ agentDisposers.clear()
300
+ }
301
+ }