dsh-acp-replay 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  A community ACP bridge for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) that answers `session/load`, so a client can rebuild a session transcript after its own restart.
4
4
 
5
- **Status: prototype.** It works end to end on `dsh 0.1.5-rc.1`, but it is a vendored fork of `@deepseek-ai/dsh-acp` and needs a rebase whenever that package changes.
5
+ **Status: prototype.** It works end to end on `dsh 0.1.5-rc.1` through `0.1.5-rc.3`, but it is a vendored fork of `@deepseek-ai/dsh-acp` and needs a rebase whenever that package changes. `0.1.6-alpha.1` changed the persistence API and, per a field report on 2026-09-23, answers no ACP request with either profile; the guard below is what reports why.
6
+
7
+ Published on npm as `dsh-acp-replay`. Its version is independent of the harness version it targets, which `peerDependencies` states. `0.1.2` ships `test/*.mjs` and exposes the self-check and the pin check as bins, so both run from an installed copy. (`0.1.5-rc.1` was published with an incomplete file list, so it fails to load; it is deprecated.)
6
8
 
7
9
  ## Why this exists
8
10
 
@@ -40,7 +42,30 @@ Use the shipped bridge instead ("dsh --profile acp"), or update this plugin for
40
42
  https://github.com/zheng1/dsh-acp-replay
41
43
  ```
42
44
 
43
- Run `node test/selfcheck.mjs` after a harness upgrade to get that answer without a full client.
45
+ Run the self-check after a harness upgrade to get that answer without a full client. From a checkout that is `node test/selfcheck.mjs`; from an installed copy it is `npx dsh-acp-replay-selfcheck`, and a global install puts the same command on `PATH`. `dsh-acp-replay-pincheck` runs both profiles and prints which of the upgrade outcomes below applies.
46
+
47
+ The self-check reports "no ACP initialize response" when the harness never answers, which is also what a failed mount looks like. `dsh` rewrites the profile's `cordis.yml` while preparing it, so a sandbox that denies writes to `DSH_HOME` exits on EPERM before answering; the check says so when it sees `EPERM` or `prepareProfile` in the captured stderr, and prints that stderr otherwise.
48
+
49
+ ## Upgrading the harness pin
50
+
51
+ The pin is the harness version whose `dsh-acp` this fork was rebased against. Check the candidate before moving it:
52
+
53
+ ```
54
+ $ DSH_BIN=~/.local/bin/dsh npx dsh-acp-replay-pincheck
55
+ harness: /Users/you/.local/bin/dsh (0.1.5-rc.3)
56
+ shipped profile "acp" does not advertise loadSession
57
+ bridge profile "acp-replay" advertises loadSession (image: yes)
58
+
59
+ PIN: bridge — upgrade the harness and move the pin; this bridge answers session/load on it
60
+ ```
61
+
62
+ | Outcome | Meaning | What to do |
63
+ | --- | --- | --- |
64
+ | `upstream` | The shipped profile advertises `loadSession` too | Drop the bridge and unpin; point the client back at `dsh --profile acp` |
65
+ | `bridge` | Only the bridged profile advertises it | Upgrade the harness, then move the pin |
66
+ | `refuses` | Neither profile advertises it | Stay on the pinned harness |
67
+
68
+ `test/replay-check.mjs` is the other half of that check: it records a session and replays it in a new process, so use a session with real content. A 376-byte session legitimately replays nothing, and zero counts then look like a broken bridge.
44
69
 
45
70
  ## Use it
46
71
 
@@ -95,6 +120,21 @@ npm run build # tsc -> lib/
95
120
  npm test # guard behaviour (node:test)
96
121
  ```
97
122
 
123
+ ### pnpm
124
+
125
+ Reported from a field install with pnpm 12 (this repository is developed with pnpm 10, where a plain install is enough): pnpm 12 fails on `ERR_PNPM_IGNORED_BUILDS` even when the dependency tree resolved, and `--ignore-scripts` does not bypass it. Declaring the builds is what works:
126
+
127
+ ```yaml
128
+ allowBuilds:
129
+ "@deepseek-ai/dsh-subprocess-local": true
130
+ "@google/genai": true
131
+ koffi: true
132
+ node-pty: true
133
+ protobufjs: true
134
+ ```
135
+
136
+ `@deepseek-ai/dsh-subprocess-local` is the one that matters: its postinstall restores the executable bit on node-pty's `spawn-helper`. A packaged prebuild already ships that file `0755`, so skipping the script is invisible until an install lacks the bit and pty support disappears without a message.
137
+
98
138
  ## Verified
99
139
 
100
140
  Against `dsh 0.1.5-rc.1`, a scratch `DSH_HOME`, and a real API key:
@@ -114,9 +154,31 @@ Against `dsh 0.1.5-rc.1`, a scratch `DSH_HOME`, and a real API key:
114
154
 
115
155
  Paseo's ACP adapter takes the `loadSession` branch only when the agent advertises the capability, primes its timeline from `streamHistory()`, and needed no change for this to work.
116
156
 
157
+ **Independent confirmation** (`dsh 0.1.5-rc.2`, Paseo 0.9.1 daemon, macOS, own `DSH_HOME`):
158
+
159
+ | Probe | Shipped `acp` | `dsh-acp-replay` |
160
+ | --- | --- | --- |
161
+ | `initialize.agentCapabilities.loadSession` | absent (`sessionCapabilities: { close, list, resume }`) | `true` |
162
+ | `session/load` on a persisted session | no replay | `user_message_chunk` ×1, `agent_message_chunk` ×17, `tool_call` ×24 |
163
+
164
+ That client-side branch was read off the shipped Paseo 0.9.1 build: its ACP client tests `agentCapabilities.loadSession` first and only falls back to `unstable_resumeSession`, and `dsh` reaches the client through the generic ACP path, so the shipped bridge takes the second branch. Switching the provider to this bridge rendered the conversations that had reopened blank, and `paseo provider diagnostic dsh` reported Ready.
165
+
166
+ **The self-check from an installed copy** (`0.1.1`, an `npm install` of the packed tarball, `dsh 0.1.5-rc.1`, scratch `DSH_HOME`) — the check that could not be run before `0.1.1`, because `test/` was not in the published file list:
167
+
168
+ ```
169
+ $ DSH_PROFILE=acp-replay dsh-acp-replay-selfcheck
170
+ OK: profile "acp-replay" advertises loadSession (image: yes)
171
+
172
+ $ DSH_PROFILE=acp dsh-acp-replay-selfcheck
173
+ FAIL: the bridge answered but does not advertise loadSession # exit 1
174
+ ```
175
+
176
+ **`0.1.1` changed no served code.** A downstream field check diffed the published `0.1.1` tarball against the installed `0.1.0`: `lib/` and `cordis.patch.yml` byte-identical, `package.json` differing only in the version, the `bin` entry, and `files`. After the upgrade the same 1.2 MB session replayed to identical counts (`user_message_chunk` 17, `agent_message_chunk` 90, `agent_thought_chunk` 103, `tool_call` 114, `tool_call_update` 114, `usage_update` 113) and the provider diagnostic stayed Ready.
177
+
117
178
  ## Not verified / known limits
118
179
 
119
180
  - Tool-call replay is mapped but not exercised by the test above.
181
+ - The `0.1.6-alpha.1` no-response finding above is a field report; this repository has not re-probed an alpha harness. npm `latest` is `0.1.5-rc.2` and `next` is `0.1.5-rc.3`, both of which work.
120
182
  - An older host (`dsh 0.1.2-rc.1`) exposes a different persistence API (`load`/`inspect` instead of `open`/`read`), so this fork targets `0.1.5-rc.1` and newer only.
121
183
  - Replayed history is delivered before `session/load` returns, one notification at a time; a very long session makes that call proportionally long.
122
184
  - The profile's own help text still reads `--profile acp` (it comes from the bundled `dsh-acp-app` command provider, not from this package).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-acp-replay",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Community ACP bridge for DeepSeek Harness that supports session/load by replaying a persisted session's transcript",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -13,10 +13,15 @@
13
13
  "./cordis.patch.yml": "./cordis.patch.yml",
14
14
  "./package.json": "./package.json"
15
15
  },
16
+ "bin": {
17
+ "dsh-acp-replay-selfcheck": "./test/selfcheck.mjs",
18
+ "dsh-acp-replay-pincheck": "./test/pin-check.mjs"
19
+ },
16
20
  "files": [
17
21
  "lib/**/*.js",
18
22
  "lib/types/**/*.d.ts",
19
- "cordis.patch.yml"
23
+ "cordis.patch.yml",
24
+ "test/*.mjs"
20
25
  ],
21
26
  "license": "MIT",
22
27
  "dsh": {
@@ -0,0 +1,47 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { assertReplayHostSupport, replayHostProblems } from '../lib/compat.js'
4
+
5
+ const noop = () => {}
6
+
7
+ test('accepts a harness with the persistence handle API', () => {
8
+ const support = {
9
+ persistence: { create: noop, open: noop, flush: noop, stat: noop, list: noop },
10
+ sessionLoadMethod: 'session/load',
11
+ }
12
+ assert.deepEqual(replayHostProblems(support), [])
13
+ assertReplayHostSupport(support)
14
+ })
15
+
16
+ test('names the service-level read API that replaced it', () => {
17
+ const legacy = {
18
+ persistence: { load: noop, inspect: noop, stat: noop, list: noop },
19
+ sessionLoadMethod: 'session/load',
20
+ }
21
+ const problems = replayHostProblems(legacy)
22
+ assert.equal(problems.length, 1)
23
+ assert.match(problems[0].requirement, /sessionPersistence\.open\(\)/)
24
+ assert.match(problems[0].detail, /inspect, list, load, stat/)
25
+ assert.throws(() => assertReplayHostSupport(legacy), /cannot serve session\/load/)
26
+ })
27
+
28
+ test('refuses a host with no persistence service mounted', () => {
29
+ assert.throws(
30
+ () => assertReplayHostSupport({ persistence: undefined, sessionLoadMethod: 'session/load' }),
31
+ /not mounted/,
32
+ )
33
+ })
34
+
35
+ test('refuses an ACP SDK without the session/load constant', () => {
36
+ assert.throws(
37
+ () => assertReplayHostSupport({ persistence: { open: noop, stat: noop }, sessionLoadMethod: undefined }),
38
+ /session\/load method constant/,
39
+ )
40
+ })
41
+
42
+ test('reports the harness version when the caller knows it', () => {
43
+ assert.throws(
44
+ () => assertReplayHostSupport({ persistence: undefined, sessionLoadMethod: 'session/load', harnessVersion: '0.1.6' }),
45
+ /harness 0\.1\.6/,
46
+ )
47
+ })
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pin check: probe a candidate harness on the shipped profile and on the bridged
4
+ * one, and print which of the three outcomes applies, so moving the harness pin
5
+ * after an upgrade is one command instead of a judgement call.
6
+ *
7
+ * upstream the shipped profile advertises loadSession itself — drop the bridge
8
+ * and unpin: point the client back at `dsh --profile acp`
9
+ * bridge only the bridged profile advertises it — upgrade the harness and
10
+ * move the pin
11
+ * refuses neither profile answers with loadSession — stay on the pinned harness
12
+ *
13
+ * Usage:
14
+ * DSH_BIN=~/.local/bin/dsh dsh-acp-replay-pincheck
15
+ *
16
+ * Env: DSH_BIN (default: dsh), DSH_SHIPPED_PROFILE (acp), DSH_PROFILE (acp-replay),
17
+ * PIN_CHECK_TIMEOUT_MS (60000).
18
+ */
19
+
20
+ import { spawn } from 'node:child_process'
21
+
22
+ import { OUTCOMES, describeImage, explainMissingResponse, probeProfile } from './probe.mjs'
23
+
24
+ const bin = process.env.DSH_BIN ?? 'dsh'
25
+ const shippedProfile = process.env.DSH_SHIPPED_PROFILE ?? 'acp'
26
+ const bridgeProfile = process.env.DSH_PROFILE ?? 'acp-replay'
27
+ const timeoutMs = Number(process.env.PIN_CHECK_TIMEOUT_MS ?? 60000)
28
+
29
+ function harnessVersion() {
30
+ return new Promise((resolve) => {
31
+ const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] })
32
+ let stdout = ''
33
+ child.stdout.on('data', (chunk) => {
34
+ stdout += chunk.toString()
35
+ })
36
+ child.on('error', () => resolve(null))
37
+ child.on('exit', () => resolve(stdout.split('\n')[0].trim() || null))
38
+ })
39
+ }
40
+
41
+ function report(label, profile, result) {
42
+ const answered =
43
+ result.kind === OUTCOMES.loadSession
44
+ ? `advertises loadSession (${describeImage(result)})`
45
+ : result.message
46
+ return `${`${label} profile "${profile}"`.padEnd(30)} ${answered}`
47
+ }
48
+
49
+ const [version, shipped, bridged] = await Promise.all([
50
+ harnessVersion(),
51
+ probeProfile({ bin, profile: shippedProfile, timeoutMs }),
52
+ probeProfile({ bin, profile: bridgeProfile, timeoutMs }),
53
+ ])
54
+
55
+ console.log(`harness: ${bin}${version ? ` (${version})` : ''}`)
56
+ console.log(report('shipped', shippedProfile, shipped))
57
+ console.log(report('bridge', bridgeProfile, bridged))
58
+
59
+ let outcome
60
+ let advice
61
+ if (shipped.kind === OUTCOMES.loadSession) {
62
+ outcome = 'upstream'
63
+ advice = `drop the bridge and unpin; point the client at \`${bin} --profile ${shippedProfile}\``
64
+ } else if (bridged.kind === OUTCOMES.loadSession) {
65
+ outcome = 'bridge'
66
+ advice = 'upgrade the harness and move the pin; this bridge answers session/load on it'
67
+ } else {
68
+ outcome = 'refuses'
69
+ advice = 'stay on the pinned harness; neither profile advertises session/load'
70
+ }
71
+
72
+ console.log(`\nPIN: ${outcome} — ${advice}`)
73
+
74
+ for (const result of [shipped, bridged]) {
75
+ const hint = explainMissingResponse(result)
76
+ if (hint) console.log(`\n${hint}`)
77
+ }
78
+
79
+ const answered = new Set([OUTCOMES.loadSession, OUTCOMES.noLoadSession])
80
+ for (const [profile, result] of [
81
+ [shippedProfile, shipped],
82
+ [bridgeProfile, bridged],
83
+ ]) {
84
+ if (answered.has(result.kind) || result.stderr.length === 0) continue
85
+ console.log(`\n--- ${bin} --profile ${profile} stderr ---\n${result.stderr}`)
86
+ }
package/test/probe.mjs ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Shared ACP probe for the checks in this directory.
3
+ *
4
+ * Boots one profile, performs a single `initialize`, and classifies the answer.
5
+ * Both `selfcheck.mjs` (one profile) and `pin-check.mjs` (shipped profile plus the
6
+ * bridged one) are built on this, so the two report the same thing about the same
7
+ * harness.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+
12
+ export const OUTCOMES = {
13
+ loadSession: 'load-session',
14
+ noLoadSession: 'no-load-session',
15
+ noResponse: 'no-response',
16
+ initializeFailed: 'initialize-failed',
17
+ spawnFailed: 'spawn-failed',
18
+ exited: 'exited',
19
+ }
20
+
21
+ /**
22
+ * @returns {Promise<{kind: string, message: string, image: boolean | null, stderr: string}>}
23
+ */
24
+ export async function probeProfile({ bin, profile, timeoutMs = 60000 }) {
25
+ return new Promise((resolve) => {
26
+ const child = spawn(bin, ['--profile', profile], { stdio: ['pipe', 'pipe', 'pipe'] })
27
+
28
+ let stdout = ''
29
+ let stderr = ''
30
+ let settled = false
31
+
32
+ function finish(result) {
33
+ if (settled) return
34
+ settled = true
35
+ clearTimeout(timer)
36
+ child.kill('SIGTERM')
37
+ resolve({ image: null, ...result, stderr: stderr.trim() })
38
+ }
39
+
40
+ const timer = setTimeout(
41
+ () =>
42
+ finish({
43
+ kind: OUTCOMES.noResponse,
44
+ message: `no ACP initialize response within ${timeoutMs}ms`,
45
+ }),
46
+ timeoutMs,
47
+ )
48
+
49
+ child.on('error', (error) =>
50
+ finish({ kind: OUTCOMES.spawnFailed, message: `could not start ${bin}: ${error.message}` }),
51
+ )
52
+ child.on('exit', (code) =>
53
+ finish({
54
+ kind: OUTCOMES.exited,
55
+ message: `${bin} exited early with code ${code} before answering`,
56
+ }),
57
+ )
58
+
59
+ child.stderr.on('data', (chunk) => {
60
+ stderr += chunk.toString()
61
+ })
62
+
63
+ child.stdout.on('data', (chunk) => {
64
+ stdout += chunk.toString()
65
+ let index
66
+ while ((index = stdout.indexOf('\n')) >= 0) {
67
+ const line = stdout.slice(0, index).trim()
68
+ stdout = stdout.slice(index + 1)
69
+ if (line.length === 0) continue
70
+ let message
71
+ try {
72
+ message = JSON.parse(line)
73
+ } catch {
74
+ continue
75
+ }
76
+ if (message.id !== 1) continue
77
+ if (message.error) {
78
+ finish({
79
+ kind: OUTCOMES.initializeFailed,
80
+ message: `initialize failed: ${JSON.stringify(message.error).slice(0, 200)}`,
81
+ })
82
+ continue
83
+ }
84
+ const capabilities = message.result?.agentCapabilities
85
+ finish({
86
+ kind:
87
+ capabilities?.loadSession === true ? OUTCOMES.loadSession : OUTCOMES.noLoadSession,
88
+ message:
89
+ capabilities?.loadSession === true
90
+ ? 'advertises loadSession'
91
+ : 'the bridge answered but does not advertise loadSession',
92
+ image: capabilities?.promptCapabilities?.image === true,
93
+ })
94
+ }
95
+ })
96
+
97
+ child.stdin.write(
98
+ `${JSON.stringify({
99
+ jsonrpc: '2.0',
100
+ id: 1,
101
+ method: 'initialize',
102
+ params: { protocolVersion: 1, clientCapabilities: {} },
103
+ })}\n`,
104
+ )
105
+ })
106
+ }
107
+
108
+ export function describeImage(result) {
109
+ return result.image === true ? 'image: yes' : 'image: no'
110
+ }
111
+
112
+ /**
113
+ * `dsh` rewrites the profile's `cordis.yml` while preparing it, so a sandbox that
114
+ * denies writes to `DSH_HOME` exits on EPERM before answering — which the probe can
115
+ * only report as "no response", the same text a failed mount produces.
116
+ */
117
+ export function explainMissingResponse(result) {
118
+ if (result.kind === OUTCOMES.loadSession || result.kind === OUTCOMES.noLoadSession) return null
119
+ if (/EPERM|prepareProfile|cordis\.yml/i.test(result.stderr)) {
120
+ return 'the harness could not write its profile: run this where DSH_HOME is writable (look for EPERM or prepareProfile in its stderr below)'
121
+ }
122
+ return null
123
+ }
@@ -0,0 +1,46 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+
4
+ import { OUTCOMES, describeImage, explainMissingResponse } from './probe.mjs'
5
+
6
+ test('reports loadSession and the image capability', () => {
7
+ assert.equal(describeImage({ image: true }), 'image: yes')
8
+ assert.equal(describeImage({ image: false }), 'image: no')
9
+ assert.equal(describeImage({ image: null }), 'image: no')
10
+ })
11
+
12
+ test('explains a silent probe whose harness could not write its profile', () => {
13
+ const hint = explainMissingResponse({
14
+ kind: OUTCOMES.noResponse,
15
+ stderr: 'Error: EPERM: operation not permitted, open cordis.yml',
16
+ })
17
+
18
+ assert.match(hint ?? '', /DSH_HOME is writable/)
19
+ })
20
+
21
+ test('explains a probe that failed while preparing the profile', () => {
22
+ const hint = explainMissingResponse({
23
+ kind: OUTCOMES.exited,
24
+ stderr: 'prepareProfile failed: read-only file system',
25
+ })
26
+
27
+ assert.match(hint ?? '', /DSH_HOME is writable/)
28
+ })
29
+
30
+ test('leaves an answered probe alone', () => {
31
+ assert.equal(
32
+ explainMissingResponse({ kind: OUTCOMES.loadSession, stderr: 'EPERM cordis.yml' }),
33
+ null,
34
+ )
35
+ assert.equal(
36
+ explainMissingResponse({ kind: OUTCOMES.noLoadSession, stderr: 'prepareProfile' }),
37
+ null,
38
+ )
39
+ })
40
+
41
+ test('does not guess at an unrelated failure', () => {
42
+ assert.equal(
43
+ explainMissingResponse({ kind: OUTCOMES.noResponse, stderr: 'some other error' }),
44
+ null,
45
+ )
46
+ })
@@ -0,0 +1,98 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { mkdirSync, writeFileSync } from 'node:fs'
3
+
4
+ const dsh = process.env.DSH_BIN
5
+ const home = process.env.DSH_HOME
6
+ const profile = process.env.DSH_PROFILE ?? 'acp-replay'
7
+ const cwd = process.env.TEST_CWD
8
+
9
+ function start() {
10
+ const child = spawn(dsh, ['--profile', profile], {
11
+ cwd,
12
+ stdio: ['pipe', 'pipe', 'pipe'],
13
+ env: { ...process.env },
14
+ })
15
+ child.stderr.on('data', (d) => process.stderr.write(`[stderr] ${String(d).slice(0, 200)}\n`))
16
+ const state = { buffer: '', pending: new Map(), nextId: 1, notifications: [] }
17
+ child.stdout.on('data', (chunk) => {
18
+ state.buffer += chunk.toString()
19
+ let i
20
+ while ((i = state.buffer.indexOf('\n')) >= 0) {
21
+ const line = state.buffer.slice(0, i).trim()
22
+ state.buffer = state.buffer.slice(i + 1)
23
+ if (!line) continue
24
+ const msg = JSON.parse(line)
25
+ if (msg.method) {
26
+ if (msg.method === 'session/update') state.notifications.push(msg.params)
27
+ if (msg.id !== undefined) {
28
+ if (msg.method === 'session/request_permission') {
29
+ const options = msg.params?.options ?? []
30
+ const allow = options.find((o) => String(o.kind).startsWith('allow')) ?? options[0]
31
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { outcome: { outcome: 'selected', optionId: allow?.optionId } } })}\n`)
32
+ } else {
33
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`)
34
+ }
35
+ }
36
+ continue
37
+ }
38
+ const resolve = state.pending.get(msg.id)
39
+ if (resolve) {
40
+ state.pending.delete(msg.id)
41
+ resolve(msg)
42
+ }
43
+ }
44
+ })
45
+ const request = (method, params, timeoutMs = 300000) => {
46
+ const id = state.nextId++
47
+ return new Promise((resolve, reject) => {
48
+ const timer = setTimeout(() => reject(new Error(`${method} timed out`)), timeoutMs)
49
+ state.pending.set(id, (msg) => {
50
+ clearTimeout(timer)
51
+ resolve(msg)
52
+ })
53
+ child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`)
54
+ })
55
+ }
56
+ return { child, request, state }
57
+ }
58
+
59
+ const phase = process.argv[2]
60
+
61
+ if (phase === 'record') {
62
+ const { child, request, state } = start()
63
+ const init = await request('initialize', { protocolVersion: 1, clientCapabilities: {} })
64
+ console.log('loadSession advertised:', init.result?.agentCapabilities?.loadSession === true)
65
+ const created = await request('session/new', { cwd, mcpServers: [] })
66
+ const sessionId = created.result.sessionId
67
+ for (const text of ['Reply with exactly: ALPHA', 'Reply with exactly: BETA']) {
68
+ const out = await request('session/prompt', { sessionId, prompt: [{ type: 'text', text }] })
69
+ console.log(`prompt "${text}":`, out.error ? `ERROR ${JSON.stringify(out.error).slice(0, 160)}` : JSON.stringify(out.result))
70
+ }
71
+ const live = state.notifications.map((n) => n.update.sessionUpdate)
72
+ console.log('live updates:', [...new Set(live)].join(', '))
73
+ await request('session/close', { sessionId })
74
+ writeFileSync('/tmp/dsh-replay-session.txt', sessionId)
75
+ console.log('SESSION', sessionId)
76
+ child.stdin.end()
77
+ setTimeout(() => { child.kill('SIGTERM'); process.exit(0) }, 300)
78
+ } else {
79
+ const sessionId = process.argv[3]
80
+ const { child, request, state } = start()
81
+ const init = await request('initialize', { protocolVersion: 1, clientCapabilities: {} })
82
+ console.log('loadSession advertised:', init.result?.agentCapabilities?.loadSession === true)
83
+ state.notifications.length = 0
84
+ const out = await request('session/load', { sessionId, cwd, mcpServers: [] })
85
+ if (out.error) {
86
+ console.log('LOAD ERROR:', JSON.stringify(out.error).slice(0, 300))
87
+ } else {
88
+ const updates = state.notifications.map((n) => n.update)
89
+ const kinds = updates.reduce((acc, u) => ({ ...acc, [u.sessionUpdate]: (acc[u.sessionUpdate] ?? 0) + 1 }), {})
90
+ console.log('replayed updates:', JSON.stringify(kinds))
91
+ const users = updates.filter((u) => u.sessionUpdate === 'user_message_chunk').map((u) => u.content?.text)
92
+ const agents = updates.filter((u) => u.sessionUpdate === 'agent_message_chunk').map((u) => u.content?.text)
93
+ console.log('replayed user texts:', JSON.stringify(users))
94
+ console.log('replayed agent texts:', JSON.stringify(agents.map((t) => (t ?? '').slice(0, 40))))
95
+ }
96
+ child.stdin.end()
97
+ setTimeout(() => { child.kill('SIGTERM'); process.exit(0) }, 300)
98
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Self-check for the dsh-acp-replay bridge.
4
+ *
5
+ * Boots one profile, performs one ACP `initialize`, and reports whether the
6
+ * bridge answered and advertised `loadSession`. Run it after any harness
7
+ * upgrade: if the vendored bridge no longer matches the installed DSH, the
8
+ * mount-time guard prints its reason to stderr and this script exits non-zero
9
+ * with that text, instead of leaving "the provider will not start" unexplained.
10
+ *
11
+ * Usage:
12
+ * DSH_BIN=dsh DSH_PROFILE=acp-replay dsh-acp-replay-selfcheck
13
+ */
14
+
15
+ import { OUTCOMES, describeImage, explainMissingResponse, probeProfile } from './probe.mjs'
16
+
17
+ const bin = process.env.DSH_BIN ?? 'dsh'
18
+ const profile = process.env.DSH_PROFILE ?? 'acp-replay'
19
+ const timeoutMs = Number(process.env.SELFCHECK_TIMEOUT_MS ?? 60000)
20
+
21
+ const result = await probeProfile({ bin, profile, timeoutMs })
22
+ const ok = result.kind === OUTCOMES.loadSession
23
+
24
+ console.log(
25
+ ok
26
+ ? `OK: profile "${profile}" ${result.message} (${describeImage(result)})`
27
+ : `FAIL: ${result.message}`,
28
+ )
29
+
30
+ const hint = explainMissingResponse(result)
31
+ if (hint) console.log(`\n${hint}`)
32
+
33
+ if (!ok && result.stderr.length > 0) {
34
+ console.log(`\n--- ${bin} stderr ---\n${result.stderr}`)
35
+ }
36
+
37
+ process.exit(ok ? 0 : 1)