@1agh/maude 0.39.0 → 0.39.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.
@@ -8,7 +8,7 @@
8
8
  // (env.ts): the child inherits the environment MINUS `ANTHROPIC_API_KEY`, so
9
9
  // auth precedence falls through to the user's Pro/Max subscription.
10
10
 
11
- import { appendFile, mkdir } from 'node:fs/promises';
11
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
12
12
  import { dirname } from 'node:path';
13
13
 
14
14
  import {
@@ -71,6 +71,29 @@ const EFFORT_THINKING_TOKENS: Record<string, number | null> = {
71
71
 
72
72
  export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
73
73
 
74
+ // Real sessionIds are adapter-generated `randomUUID()`s. A persisted value that
75
+ // doesn't look like one (corrupt sidecar, or a tracked file a cloned repo
76
+ // shipped despite `_chat/` being gitignored — DDR-115) is rejected rather than
77
+ // forwarded into the privileged `loadSession` ACP call.
78
+ const VALID_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
79
+
80
+ // `loadSession`'s replay can, in principle, never settle if the underlying
81
+ // transport dies mid-call (adapter crash, a concurrent `stop()` from another
82
+ // chat sharing this bridge). Bound it so `replaying` always resets and a
83
+ // resume attempt always falls back to `newSession` instead of wedging the
84
+ // bridge silent forever. Mirrors the `withTimeout`/`TIMED_OUT` pattern already
85
+ // used for network calls in `apps/studio/git/service.ts`.
86
+ const LOAD_SESSION_TIMEOUT_MS = 15_000;
87
+ const TIMED_OUT = Symbol('maude-acp-load-session-timeout');
88
+ function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
89
+ p.catch(() => {});
90
+ let timer: ReturnType<typeof setTimeout>;
91
+ const t = new Promise<typeof TIMED_OUT>((res) => {
92
+ timer = setTimeout(() => res(TIMED_OUT), ms);
93
+ });
94
+ return Promise.race([p, t]).finally(() => clearTimeout(timer));
95
+ }
96
+
74
97
  /**
75
98
  * Build the `session/new` params, carrying TWO adapter-internal `_meta` payloads
76
99
  * (both spread by the installed `claude-agent-acp@0.49.x` `newSession`):
@@ -90,6 +113,10 @@ export type AcpEffort = keyof typeof EFFORT_THINKING_TOKENS;
90
113
  * must fail the presence tests LOUDLY (acp-bootstrap-brief.test.ts +
91
114
  * acp-session-plugins.test.ts), not silently un-brief / un-plugin every session.
92
115
  * Exported for those tests.
116
+ *
117
+ * `cwd`/`mcpServers`/`_meta` are also exactly the shared fields of a
118
+ * `LoadSessionRequest` (schema/types.gen.d.ts) — `sessionFor`'s resume path
119
+ * spreads this same object and adds `sessionId` rather than duplicating it.
93
120
  */
94
121
  export function newSessionParams(
95
122
  repoRoot: string,
@@ -136,12 +163,21 @@ export class AcpBridge {
136
163
  // context. The adapter (one subprocess) holds them all; switching chats reuses
137
164
  // the session, so claude remembers that chat while the app is open.
138
165
  private sessions = new Map<string, string>(); // chatId → sessionId
166
+ // In-flight sessionFor() calls, keyed by chatId — lets a `warm` and a `prompt`
167
+ // racing for the same chat share one resume/create attempt instead of each
168
+ // running establishSession() and stomping the single shared `replaying` flag.
169
+ private sessionPromises = new Map<string, Promise<string>>();
139
170
  private currentSession: string | null = null; // the in-flight prompt's session
140
171
  /** Sessions whose bootstrap brief already hit the transcript (audit record). */
141
172
  private briefLogged = new Set<string>();
142
173
  private starting: Promise<void> | null = null;
143
174
  /** Per-chat transcript file (`_chat/<id>.jsonl`); set per prompt. */
144
175
  private transcriptPath: string | null = null;
176
+ /** Sidecar persisting this chat's ACP sessionId across restarts (`_chat/<id>.session.json`). */
177
+ private sessionStorePath: string | null = null;
178
+ /** True while `conn.loadSession()` is replaying a resumed session's history
179
+ * back through the `sessionUpdate` client callback — see the guard in `start()`. */
180
+ private replaying = false;
145
181
  // Model + effort are env-at-spawn (ANTHROPIC_MODEL / MAX_THINKING_TOKENS), so a
146
182
  // change re-spawns the adapter. `desired*` is what the UI asked for; `active*`
147
183
  // is what the running session was spawned with.
@@ -165,6 +201,10 @@ export class AcpBridge {
165
201
  this.transcriptPath = path;
166
202
  }
167
203
 
204
+ setSessionStorePath(path: string | null): void {
205
+ this.sessionStorePath = path;
206
+ }
207
+
168
208
  /** Desired model (alias/id, or null for the user's default) + effort. Applied
169
209
  * on the next prompt — re-spawning the adapter only if it actually changed. */
170
210
  setConfig(model: string | null, effort: AcpEffort): void {
@@ -187,18 +227,104 @@ export class AcpBridge {
187
227
  return this.starting;
188
228
  }
189
229
 
190
- /** Get-or-create the ACP session for a chat id (one claude context per chat). */
230
+ /**
231
+ * Get-or-create the ACP session for a chat id (one claude context per chat).
232
+ * `warm` and `prompt` can both reach this for the same chat close together
233
+ * (composer autocomplete warm-up racing the user hitting send) — a second
234
+ * concurrent call for the same chatId shares the FIRST call's in-flight
235
+ * promise (mirrors `ensureStarted`'s `this.starting` pattern) rather than
236
+ * re-entering resume/create and stomping the single shared `replaying` flag.
237
+ */
191
238
  private async sessionFor(chatId: string): Promise<string> {
192
239
  const existing = this.sessions.get(chatId);
193
240
  if (existing) return existing;
241
+ const inFlight = this.sessionPromises.get(chatId);
242
+ if (inFlight) return inFlight;
243
+
244
+ const promise = this.establishSession(chatId).finally(() => {
245
+ this.sessionPromises.delete(chatId);
246
+ });
247
+ this.sessionPromises.set(chatId, promise);
248
+ return promise;
249
+ }
250
+
251
+ /**
252
+ * Resumes a session persisted from a PRIOR app/dev-server lifetime (the
253
+ * cross-restart memory gap tracked in DDR-125) via the adapter's `loadSession`
254
+ * before falling back to a brand-new `newSession` — either because this chat
255
+ * has never had a session, or because the resume attempt failed (e.g. the
256
+ * underlying claude session was pruned, `claude` was reinstalled, or the
257
+ * adapter's response never arrives — `loadSession` is time-boxed so a dead
258
+ * transport can't wedge `replaying` true forever and silently black-hole
259
+ * every future turn on this bridge).
260
+ */
261
+ private async establishSession(chatId: string): Promise<string> {
194
262
  if (!this.conn) throw new Error('ACP adapter not started');
195
- const created = await this.conn.newSession(
196
- newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins)
197
- );
263
+
264
+ const params = newSessionParams(this.opts.repoRoot, this.opts.studioBrief, this.opts.plugins);
265
+ const persistedId = await this.readPersistedSessionId();
266
+ if (persistedId) {
267
+ try {
268
+ this.replaying = true;
269
+ const result = await withTimeout(
270
+ this.conn.loadSession({ ...params, sessionId: persistedId }),
271
+ LOAD_SESSION_TIMEOUT_MS
272
+ );
273
+ if (result === TIMED_OUT) {
274
+ throw new Error(`loadSession timed out after ${LOAD_SESSION_TIMEOUT_MS}ms`);
275
+ }
276
+ this.sessions.set(chatId, persistedId);
277
+ return persistedId;
278
+ } catch (err) {
279
+ await this.appendTranscript({
280
+ role: 'bootstrap',
281
+ kind: 'resume-failed',
282
+ error: err instanceof Error ? err.message : String(err),
283
+ });
284
+ } finally {
285
+ this.replaying = false;
286
+ }
287
+ }
288
+
289
+ const created = await this.conn.newSession(params);
198
290
  this.sessions.set(chatId, created.sessionId);
291
+ await this.writePersistedSessionId(created.sessionId);
199
292
  return created.sessionId;
200
293
  }
201
294
 
295
+ /** Read the sessionId persisted for this chat by a prior bridge lifetime.
296
+ * Null when there's no sidecar wired (e.g. warm-up before any prompt), the
297
+ * file doesn't exist yet (first-ever turn), it's unreadable/corrupt, or its
298
+ * `sessionId` doesn't look like a real one (defense-in-depth — this file's
299
+ * directory is per-machine/gitignored per DDR-115, but a cloned repo could
300
+ * still ship a tracked file there, so bound what we'll forward into the
301
+ * privileged `loadSession` ACP call rather than trusting its shape blindly). */
302
+ private async readPersistedSessionId(): Promise<string | null> {
303
+ if (!this.sessionStorePath) return null;
304
+ try {
305
+ const raw = await readFile(this.sessionStorePath, 'utf8');
306
+ const data = JSON.parse(raw) as { sessionId?: unknown };
307
+ const id = data.sessionId;
308
+ return typeof id === 'string' && VALID_SESSION_ID.test(id) ? id : null;
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ /** Persist a freshly-created sessionId so the NEXT bridge lifetime (app
315
+ * restart, dev-server restart) can resume this chat instead of starting
316
+ * fresh. Best-effort, like `appendTranscript` — a failed write just means
317
+ * the next restart falls back to a new session. */
318
+ private async writePersistedSessionId(sessionId: string): Promise<void> {
319
+ if (!this.sessionStorePath) return;
320
+ try {
321
+ await mkdir(dirname(this.sessionStorePath), { recursive: true });
322
+ await writeFile(this.sessionStorePath, JSON.stringify({ sessionId, updatedAt: Date.now() }));
323
+ } catch {
324
+ /* best-effort — see doc comment above */
325
+ }
326
+ }
327
+
202
328
  private async start(): Promise<void> {
203
329
  const adapterEntry = resolveAdapterEntry();
204
330
  if (!adapterEntry) {
@@ -279,6 +405,12 @@ export class AcpBridge {
279
405
  this.opts.onCommands?.(params.update.availableCommands ?? []);
280
406
  return;
281
407
  }
408
+ // `loadSession` replays the resumed session's entire prior history back
409
+ // through this SAME callback (claude-agent-acp's replaySessionHistory) to
410
+ // prime its own in-adapter state. That history is already on disk in the
411
+ // transcript and already rendered client-side, so forwarding/re-appending
412
+ // it here would duplicate every message in the panel and the jsonl file.
413
+ if (this.replaying) return;
282
414
  this.opts.onUpdate(params.update);
283
415
  void this.appendTranscript({ role: 'agent', update: params.update });
284
416
  },
@@ -388,6 +520,13 @@ export class AcpBridge {
388
520
  this.proc = null;
389
521
  this.conn = null;
390
522
  this.sessions.clear();
523
+ // Drop any in-flight sessionFor() promises too — they were bound to the
524
+ // now-dead `conn`; a subsequent sessionFor() for the same chatId must
525
+ // establish fresh against the respawned connection, not await a stale
526
+ // reference (each entry's own .finally() would eventually clear it once its
527
+ // bounded loadSession timeout fires, but a call landing before then would
528
+ // otherwise get back a result tied to the connection we just tore down).
529
+ this.sessionPromises.clear();
391
530
  this.briefLogged.clear();
392
531
  this.currentSession = null;
393
532
  }
@@ -185,8 +185,16 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
185
185
  const safe = id.replace(/[^a-z0-9_-]/gi, '').slice(0, 64);
186
186
  return safe || 'default';
187
187
  }
188
+ function chatFilePathFor(chatId: string, suffix: string): string {
189
+ return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}${suffix}`);
190
+ }
188
191
  function transcriptPathFor(chatId: string): string {
189
- return join(ctx.paths.designRoot, '_chat', `${sanitizeChatId(chatId)}.jsonl`);
192
+ return chatFilePathFor(chatId, '.jsonl');
193
+ }
194
+ // Sidecar persisting this chat's ACP sessionId across restarts (bridge.ts
195
+ // sessionFor's resume path) — the cross-restart memory gap tracked in DDR-125.
196
+ function sessionStorePathFor(chatId: string): string {
197
+ return chatFilePathFor(chatId, '.session.json');
190
198
  }
191
199
 
192
200
  async function handlePrompt(
@@ -198,6 +206,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
198
206
  ): Promise<void> {
199
207
  const bridge = getOrCreateBridge(ws);
200
208
  bridge.setTranscriptPath(transcriptPathFor(chatId));
209
+ bridge.setSessionStorePath(sessionStorePathFor(chatId));
201
210
  bridge.setConfig(model, effort);
202
211
  try {
203
212
  await bridge.ensureStarted();
@@ -226,6 +235,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
226
235
  effort: AcpEffort
227
236
  ): Promise<void> {
228
237
  const bridge = getOrCreateBridge(ws);
238
+ bridge.setSessionStorePath(sessionStorePathFor(chatId));
229
239
  bridge.setConfig(model, effort);
230
240
  try {
231
241
  await bridge.warmUp(sanitizeChatId(chatId));
@@ -4,6 +4,8 @@
4
4
  // (DDR-123 guardrail #1, verified end-to-end — the mock echoes its own env).
5
5
 
6
6
  import { afterEach, describe, expect, test } from 'bun:test';
7
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
8
+ import { tmpdir } from 'node:os';
7
9
  import { join } from 'node:path';
8
10
 
9
11
  import { AcpBridge } from '../acp/bridge.ts';
@@ -112,6 +114,136 @@ describe('AcpBridge — round-trip + subscription guardrail', () => {
112
114
  }, 15000);
113
115
  });
114
116
 
117
+ describe('AcpBridge — cross-restart session resume (DDR-125 gap)', () => {
118
+ let dir: string;
119
+
120
+ afterEach(async () => {
121
+ if (dir) await rm(dir, { recursive: true, force: true });
122
+ });
123
+
124
+ test('a persisted sessionId resumes via loadSession instead of spawning a new session', async () => {
125
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
126
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
127
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
128
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
129
+ const storePath = join(dir, 'c1.session.json');
130
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-session-abc' }));
131
+
132
+ const updates: unknown[] = [];
133
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
134
+ try {
135
+ bridge.setSessionStorePath(storePath);
136
+ await bridge.prompt('pokracuj', 'c1');
137
+ // Resumed the persisted id verbatim — never fell through to session/new
138
+ // (which would have minted a fresh `mock-session-N`).
139
+ expect(bridge.sessionId).toBe('persisted-session-abc');
140
+ // The mock's replay notification must NOT reach the UI sink.
141
+ expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
142
+ } finally {
143
+ await bridge.stop();
144
+ }
145
+ }, 15000);
146
+
147
+ test('a resume that fails (pruned session) falls back to newSession and re-persists the new id', async () => {
148
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
149
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
150
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
151
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
152
+ const storePath = join(dir, 'c1.session.json');
153
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'not-found' }));
154
+
155
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
156
+ try {
157
+ bridge.setSessionStorePath(storePath);
158
+ await bridge.prompt('pokracuj', 'c1');
159
+ expect(bridge.sessionId).toBe('mock-session-1'); // fresh session, not the stale id
160
+ const stored = JSON.parse(await readFile(storePath, 'utf8'));
161
+ expect(stored.sessionId).toBe('mock-session-1'); // re-persisted so the NEXT restart resumes it
162
+ } finally {
163
+ await bridge.stop();
164
+ }
165
+ }, 15000);
166
+
167
+ test('no prior chat for this sidecar behaves exactly as before — a brand new session is created and persisted', async () => {
168
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
169
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
170
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
171
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
172
+ const storePath = join(dir, 'brand-new.session.json');
173
+
174
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
175
+ try {
176
+ bridge.setSessionStorePath(storePath);
177
+ await bridge.prompt('hi', 'c1');
178
+ expect(bridge.sessionId).toBe('mock-session-1');
179
+ const stored = JSON.parse(await readFile(storePath, 'utf8'));
180
+ expect(stored.sessionId).toBe('mock-session-1');
181
+ } finally {
182
+ await bridge.stop();
183
+ }
184
+ }, 15000);
185
+
186
+ test('no sidecar wired at all (e.g. a warm-up before any prompt) never touches disk and behaves exactly as before', async () => {
187
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
188
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
189
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
190
+
191
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
192
+ try {
193
+ // setSessionStorePath is never called — sessionStorePath stays null.
194
+ await bridge.prompt('hi', 'c1');
195
+ expect(bridge.sessionId).toBe('mock-session-1');
196
+ } finally {
197
+ await bridge.stop();
198
+ }
199
+ }, 15000);
200
+
201
+ test('a malformed persisted sessionId (not a plausible shape) is rejected — falls back to newSession', async () => {
202
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
203
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
204
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
205
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
206
+ const storePath = join(dir, 'c1.session.json');
207
+ // Not a plausible sessionId shape (embedded newline + non-UUID charset) —
208
+ // must never reach the wire as a `loadSession` sessionId.
209
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'evil\nsessionId; rm -rf /' }));
210
+
211
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: () => {} });
212
+ try {
213
+ bridge.setSessionStorePath(storePath);
214
+ await bridge.prompt('hi', 'c1');
215
+ expect(bridge.sessionId).toBe('mock-session-1'); // rejected — fresh session instead
216
+ } finally {
217
+ await bridge.stop();
218
+ }
219
+ }, 15000);
220
+
221
+ test('warm and prompt racing for the same chat share one resume attempt (no replaying race)', async () => {
222
+ process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
223
+ process.env.MAUDE_ACP_RUNTIME = process.execPath;
224
+ process.env.MAUDE_CLAUDE_BIN = process.execPath;
225
+ dir = await mkdtemp(join(tmpdir(), 'acp-bridge-test-'));
226
+ const storePath = join(dir, 'c1.session.json');
227
+ await Bun.write(storePath, JSON.stringify({ sessionId: 'persisted-race-session' }));
228
+
229
+ const updates: unknown[] = [];
230
+ const bridge = new AcpBridge({ repoRoot: process.cwd(), onUpdate: (u) => updates.push(u) });
231
+ try {
232
+ bridge.setSessionStorePath(storePath);
233
+ // Fire warmUp and prompt concurrently for the same chatId — both call
234
+ // sessionFor('c1') before either has resolved.
235
+ const [, result] = await Promise.all([bridge.warmUp('c1'), bridge.prompt('pokracuj', 'c1')]);
236
+ expect(bridge.sessionId).toBe('persisted-race-session');
237
+ expect(result.stopReason).toBe('end_turn');
238
+ // Only ONE resume attempt happened — the mock's replay text never leaked,
239
+ // and the prompt's own turn still streamed normally.
240
+ expect(JSON.stringify(updates)).not.toContain('REPLAYED-HISTORY-SHOULD-NOT-SURFACE');
241
+ } finally {
242
+ await bridge.stop();
243
+ }
244
+ }, 15000);
245
+ });
246
+
115
247
  describe('probeAcpAvailability — not-connected detection', () => {
116
248
  test('reports not-available with a Claude-Code reason when the CLI is absent', () => {
117
249
  process.env.MAUDE_ACP_ADAPTER_ENTRY = FIXTURE;
@@ -6,6 +6,8 @@
6
6
  // the cached list to a freshly-opened socket.
7
7
 
8
8
  import { afterEach, describe, expect, test } from 'bun:test';
9
+ import { mkdtemp, rm } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
9
11
  import { join } from 'node:path';
10
12
 
11
13
  import type { ServerWebSocket } from 'bun';
@@ -73,9 +75,11 @@ describe('AcpBridge.warmUp — publishes the command catalogue without a prompt'
73
75
  describe('Acp manager — warm frame broadcasts + open replays commands', () => {
74
76
  test('a {t:warm} frame yields a {t:commands} frame, replayed to a new socket', async () => {
75
77
  useMockAgent();
76
- const ctx = {
77
- paths: { repoRoot: process.cwd(), designRoot: join(process.cwd(), '.design') },
78
- } as unknown as Context;
78
+ // Isolated tmp designRoot — `warm` now also writes a session-store sidecar
79
+ // (bridge.ts sessionFor's resume path), so this must not land in a real
80
+ // repo path.
81
+ const designRoot = await mkdtemp(join(tmpdir(), 'acp-commands-test-'));
82
+ const ctx = { paths: { repoRoot: process.cwd(), designRoot } } as unknown as Context;
79
83
  const acp = createAcp(ctx);
80
84
 
81
85
  const a = fakeWs('ws-a');
@@ -103,6 +107,7 @@ describe('Acp manager — warm frame broadcasts + open replays commands', () =>
103
107
  acp.onClose(a.ws);
104
108
  // give teardown a tick to kill the subprocess
105
109
  await new Promise((r) => setTimeout(r, 50));
110
+ await rm(designRoot, { recursive: true, force: true });
106
111
  }
107
112
  }, 20000);
108
113
  });
@@ -5,6 +5,16 @@
5
5
  // `agent_message_chunk` whose text echoes whether ANTHROPIC_API_KEY survived
6
6
  // into the child env — it MUST read `<unset>`, proving scrubAgentEnv (DDR-123
7
7
  // guardrail #1) stripped it before spawn.
8
+ //
9
+ // session/load simulates the REAL adapter's cross-restart resume (this is a
10
+ // fresh subprocess per test, so it has no memory of a prior session/new call —
11
+ // exactly like the real claude-agent-acp adapter after an app restart, whose
12
+ // resume is backed by the underlying `claude` CLI's own on-disk store, not its
13
+ // own process memory). Any sessionId resolves EXCEPT the `not-found` sentinel
14
+ // (simulates a pruned/unresumable session), so a test controls resume
15
+ // success/failure by choosing which id it persists. It also emits one replay
16
+ // `session/update` BEFORE resolving, mirroring claude-agent-acp's
17
+ // `replaySessionHistory` — bridge.ts must not forward or re-transcript it.
8
18
 
9
19
  import { Readable, Writable } from 'node:stream';
10
20
 
@@ -16,7 +26,7 @@ acp
16
26
  .agent({ name: 'mock-acp-agent' })
17
27
  .onRequest('initialize', () => ({
18
28
  protocolVersion: acp.PROTOCOL_VERSION,
19
- agentCapabilities: { loadSession: false },
29
+ agentCapabilities: { loadSession: true },
20
30
  }))
21
31
  .onRequest(
22
32
  'session/new',
@@ -25,6 +35,19 @@ acp
25
35
  return () => ({ sessionId: `mock-session-${++n}` });
26
36
  })()
27
37
  )
38
+ .onRequest('session/load', async (ctx) => {
39
+ if (ctx.params.sessionId === 'not-found') {
40
+ throw new Error('session not found');
41
+ }
42
+ await ctx.client.notify('session/update', {
43
+ sessionId: ctx.params.sessionId,
44
+ update: {
45
+ sessionUpdate: 'agent_message_chunk',
46
+ content: { type: 'text', text: 'REPLAYED-HISTORY-SHOULD-NOT-SURFACE' },
47
+ },
48
+ });
49
+ return {};
50
+ })
28
51
  .onRequest('session/prompt', async (ctx) => {
29
52
  // The per-request handler context exposes the client connection at `.client`.
30
53
  await ctx.client.notify('session/update', {
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "acp-chat-cross-restart-resume",
6
+ "version": "0.39.1",
7
+ "date": "2026-07-03",
8
+ "kind": "fix",
9
+ "title": "The Assistant remembers your conversation after a restart",
10
+ "summary": "Killed the app to grab an update, or restarted the dev server mid-chat? The Assistant now picks up the actual conversation where you left off instead of quietly starting fresh while the old messages just sat there.",
11
+ "surface": "design-ui"
12
+ },
4
13
  {
5
14
  "id": "acp-chat-image-thumbnails",
6
15
  "version": "0.39.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.39.0",
3
+ "version": "0.39.1",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,13 +50,13 @@
50
50
  "test:e2e:desktop:onboarding": "pnpm --filter @maude/desktop-e2e e2e:onboarding"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@1agh/maude-darwin-arm64": "0.39.0",
54
- "@1agh/maude-darwin-x64": "0.39.0",
55
- "@1agh/maude-linux-arm64": "0.39.0",
56
- "@1agh/maude-linux-arm64-musl": "0.39.0",
57
- "@1agh/maude-linux-x64": "0.39.0",
58
- "@1agh/maude-linux-x64-musl": "0.39.0",
59
- "@1agh/maude-win32-x64": "0.39.0"
53
+ "@1agh/maude-darwin-arm64": "0.39.1",
54
+ "@1agh/maude-darwin-x64": "0.39.1",
55
+ "@1agh/maude-linux-arm64": "0.39.1",
56
+ "@1agh/maude-linux-arm64-musl": "0.39.1",
57
+ "@1agh/maude-linux-x64": "0.39.1",
58
+ "@1agh/maude-linux-x64-musl": "0.39.1",
59
+ "@1agh/maude-win32-x64": "0.39.1"
60
60
  },
61
61
  "files": [
62
62
  "cli",