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.
@@ -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
+ }