amalgm 0.1.51 → 0.1.52

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.
Files changed (70) hide show
  1. package/lib/tunnel-events.js +48 -23
  2. package/package.json +2 -2
  3. package/runtime/lib/harnesses.js +12 -4
  4. package/runtime/scripts/amalgm-mcp/agents/store.js +5 -5
  5. package/runtime/scripts/amalgm-mcp/{artifacts → apps}/advertise.js +39 -24
  6. package/runtime/scripts/amalgm-mcp/apps/rest.js +144 -0
  7. package/runtime/scripts/amalgm-mcp/apps/store.js +171 -0
  8. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +439 -0
  9. package/runtime/scripts/amalgm-mcp/apps/tools.js +176 -0
  10. package/runtime/scripts/amalgm-mcp/automations/cell-references.js +237 -0
  11. package/runtime/scripts/amalgm-mcp/automations/context.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/automations/rest.js +148 -0
  13. package/runtime/scripts/amalgm-mcp/automations/runner.js +613 -0
  14. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +90 -0
  15. package/runtime/scripts/amalgm-mcp/automations/store.js +1125 -0
  16. package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +177 -0
  17. package/runtime/scripts/amalgm-mcp/automations/tools.js +418 -0
  18. package/runtime/scripts/amalgm-mcp/automations/validator.js +225 -0
  19. package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +505 -0
  20. package/runtime/scripts/amalgm-mcp/browser/electron-bridge.js +222 -0
  21. package/runtime/scripts/amalgm-mcp/browser/page.js +13 -631
  22. package/runtime/scripts/amalgm-mcp/browser/tools.js +9 -7
  23. package/runtime/scripts/amalgm-mcp/config.js +33 -48
  24. package/runtime/scripts/amalgm-mcp/deps.js +1 -31
  25. package/runtime/scripts/amalgm-mcp/events/ingress.js +50 -42
  26. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +169 -0
  27. package/runtime/scripts/amalgm-mcp/events/matcher.js +45 -14
  28. package/runtime/scripts/amalgm-mcp/events/store.js +106 -57
  29. package/runtime/scripts/amalgm-mcp/index.js +12 -14
  30. package/runtime/scripts/amalgm-mcp/lib/prefs.js +229 -65
  31. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +13 -27
  32. package/runtime/scripts/amalgm-mcp/server/core-tools.js +2 -3
  33. package/runtime/scripts/amalgm-mcp/server/http.js +106 -56
  34. package/runtime/scripts/amalgm-mcp/slack/inbound.js +1 -1
  35. package/runtime/scripts/amalgm-mcp/state/db.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/state/snapshot.js +16 -3
  37. package/runtime/scripts/amalgm-mcp/tasks/executor.js +1 -1
  38. package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +348 -0
  39. package/runtime/scripts/amalgm-mcp/tests/events-matcher.test.js +23 -0
  40. package/runtime/scripts/amalgm-mcp/tests/workflows-store-runner.test.js +67 -0
  41. package/runtime/scripts/amalgm-mcp/toolbox/tools.js +16 -3
  42. package/runtime/scripts/amalgm-mcp/workflows/compiler.js +222 -0
  43. package/runtime/scripts/amalgm-mcp/workflows/runner.js +593 -0
  44. package/runtime/scripts/amalgm-mcp/workflows/store.js +237 -0
  45. package/runtime/scripts/chat-core/adapters/claude.js +2 -1
  46. package/runtime/scripts/chat-core/auth.js +82 -12
  47. package/runtime/scripts/chat-core/contract.js +5 -1
  48. package/runtime/scripts/chat-core/engine.js +103 -62
  49. package/runtime/scripts/chat-core/event-schema.js +8 -0
  50. package/runtime/scripts/chat-core/events.js +5 -0
  51. package/runtime/scripts/chat-core/normalizers/codex.js +13 -1
  52. package/runtime/scripts/chat-core/parts.js +21 -6
  53. package/runtime/scripts/chat-core/sse.js +3 -0
  54. package/runtime/scripts/chat-core/tests/auth.test.js +84 -6
  55. package/runtime/scripts/chat-core/tests/engine.test.js +312 -0
  56. package/runtime/scripts/chat-core/tests/native-config.test.js +23 -0
  57. package/runtime/scripts/chat-core/tool-shape.js +4 -4
  58. package/runtime/scripts/chat-core/tooling/active-memory.js +5 -4
  59. package/runtime/scripts/chat-core/tooling/native-config.js +34 -3
  60. package/runtime/scripts/local-gateway.js +34 -27
  61. package/runtime/scripts/platform-context.txt +76 -94
  62. package/runtime/scripts/amalgm-mcp/artifacts/rest.js +0 -103
  63. package/runtime/scripts/amalgm-mcp/artifacts/store.js +0 -157
  64. package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +0 -439
  65. package/runtime/scripts/amalgm-mcp/artifacts/tools.js +0 -176
  66. package/runtime/scripts/amalgm-mcp/events/executor.js +0 -258
  67. package/runtime/scripts/amalgm-mcp/events/rest.js +0 -214
  68. package/runtime/scripts/amalgm-mcp/events/tools.js +0 -323
  69. package/runtime/scripts/amalgm-mcp/tasks/rest.js +0 -110
  70. package/runtime/scripts/amalgm-mcp/tasks/tools.js +0 -416
@@ -19,6 +19,10 @@ function textDelta(text, data = {}) {
19
19
  return event('text.delta', { streamKind: 'text', ...data, text: String(text || '') });
20
20
  }
21
21
 
22
+ function textBoundary(data = {}) {
23
+ return event('text.boundary', { streamKind: 'text', ...data });
24
+ }
25
+
22
26
  function reasoningDelta(text, data = {}) {
23
27
  return event('reasoning.delta', { streamKind: 'reasoning', ...data, text: String(text || '') });
24
28
  }
@@ -181,6 +185,7 @@ module.exports = {
181
185
  event,
182
186
  reasoningDelta,
183
187
  reasoningStarted,
188
+ textBoundary,
184
189
  textDelta,
185
190
  toolCompleted,
186
191
  toolStarted,
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { compactionFinished, compactionStarted, done, errorEvent, reasoningDelta, textDelta, toolCompleted, toolStarted, toolUpdated, usageFinal } = require('../events');
3
+ const { compactionFinished, compactionStarted, done, errorEvent, reasoningDelta, textBoundary, textDelta, toolCompleted, toolStarted, toolUpdated, usageFinal } = require('../events');
4
4
  const { stringifyResult, summarizeToolOutput, summarizeToolRequest } = require('../tool-display');
5
5
  const { normalizeError, titleForTool, toolKind } = require('../tool-shape');
6
6
  const { normalizeUsage } = require('../usage');
@@ -186,6 +186,10 @@ function isCodexCompactionItem(item = {}) {
186
186
  return item?.type === 'contextCompaction';
187
187
  }
188
188
 
189
+ function isCodexAgentMessageItem(item = {}) {
190
+ return item?.type === 'agentMessage';
191
+ }
192
+
189
193
  function normalizeCodexNotification(msg, providerSessionId, state) {
190
194
  const method = msg.method;
191
195
  const p = msg.params || msg.payload || {};
@@ -210,6 +214,14 @@ function normalizeCodexNotification(msg, providerSessionId, state) {
210
214
  ...raw,
211
215
  })];
212
216
  }
217
+ if (method === 'item/started' && isCodexAgentMessageItem(p.item)) {
218
+ return [textBoundary({
219
+ providerSessionId,
220
+ itemId: p.item.id || undefined,
221
+ reason: 'agent_message_started',
222
+ ...raw,
223
+ })];
224
+ }
213
225
  if (method === 'item/agentMessage/delta' && codexDelta(msg, p)) return [textDelta(codexDelta(msg, p), { providerSessionId, ...raw })];
214
226
  if ((method === 'item/reasoning/delta' || method === 'item/reasoning/textDelta' || method === 'item/reasoning/summaryTextDelta') && codexDelta(msg, p)) {
215
227
  return [reasoningDelta(codexDelta(msg, p), { providerSessionId, ...raw })];
@@ -66,6 +66,7 @@ class PartAccumulator {
66
66
  constructor() {
67
67
  this.parts = [];
68
68
  this.textBuffer = '';
69
+ this.currentTextPart = null;
69
70
  this.reasoningBuffer = '';
70
71
  }
71
72
 
@@ -74,12 +75,21 @@ class PartAccumulator {
74
75
  if (e.type === 'text.delta') {
75
76
  this.finishReasoning(e.ts);
76
77
  this.textBuffer += e.text || '';
77
- const last = this.parts[this.parts.length - 1];
78
- if (last?.type === 'text') last.text = this.textBuffer;
79
- else this.parts.push({ type: 'text', text: this.textBuffer });
78
+ if (this.currentTextPart) {
79
+ this.currentTextPart.text = this.textBuffer;
80
+ } else {
81
+ this.currentTextPart = { type: 'text', text: this.textBuffer };
82
+ this.parts.push(this.currentTextPart);
83
+ }
84
+ return;
85
+ }
86
+ if (e.type === 'text.boundary') {
87
+ this.finishReasoning(e.ts);
88
+ this.resetText();
80
89
  return;
81
90
  }
82
91
  if (e.type === 'reasoning.delta') {
92
+ this.resetText();
83
93
  this.reasoningBuffer += e.text || '';
84
94
  const last = this.parts[this.parts.length - 1];
85
95
  if (last?.type === 'reasoning' && last.state === 'streaming') {
@@ -94,7 +104,7 @@ class PartAccumulator {
94
104
  }
95
105
  if (e.type === 'tool.started') {
96
106
  this.finishReasoning(e.ts);
97
- this.textBuffer = '';
107
+ this.resetText();
98
108
  const existing = this.findTool(e.toolCallId);
99
109
  if (existing) {
100
110
  existing.input = { ...existing.input, ...(e.input || {}) };
@@ -164,7 +174,7 @@ class PartAccumulator {
164
174
  }
165
175
  if (e.type === 'compaction.started') {
166
176
  this.finishReasoning(e.ts);
167
- this.textBuffer = '';
177
+ this.resetText();
168
178
  const part = {
169
179
  type: 'compaction',
170
180
  phase: 'started',
@@ -182,7 +192,7 @@ class PartAccumulator {
182
192
  }
183
193
  if (e.type === 'compaction.finished') {
184
194
  this.finishReasoning(e.ts);
185
- this.textBuffer = '';
195
+ this.resetText();
186
196
  const existing = this.findOpenCompaction();
187
197
  const finishedAt = compactionIso(e.ts);
188
198
  const startedAt = existing?.startedAt || undefined;
@@ -230,6 +240,11 @@ class PartAccumulator {
230
240
  this.reasoningBuffer = '';
231
241
  }
232
242
 
243
+ resetText() {
244
+ this.textBuffer = '';
245
+ this.currentTextPart = null;
246
+ }
247
+
233
248
  finalize() {
234
249
  const ts = Date.now();
235
250
  this.finishReasoning(ts);
@@ -66,6 +66,9 @@ function toAgentStreamEvent(e, context = {}) {
66
66
  if (e.type === 'text.delta') {
67
67
  return { _type: 'update', eventTimestamp: e.ts, sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: e.text || '' } };
68
68
  }
69
+ if (e.type === 'text.boundary') {
70
+ return { _type: 'update', eventTimestamp: e.ts, sessionUpdate: 'agent_message_boundary', itemId: e.itemId || null, reason: e.reason || null };
71
+ }
69
72
  if (e.type === 'reasoning.delta') {
70
73
  return { _type: 'update', eventTimestamp: e.ts, sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: e.text || '' } };
71
74
  }
@@ -1,10 +1,69 @@
1
1
  'use strict';
2
2
 
3
3
  const assert = require('node:assert/strict');
4
+ const os = require('node:os');
4
5
  const test = require('node:test');
5
6
  const { authEnvelope, runtimeEnv } = require('../auth');
6
7
 
7
- test('claude provider auth keeps the user home environment', () => {
8
+ test('amalgm auth uses a pinned CLI home across sessions and proxy token refreshes', () => {
9
+ const first = authEnvelope({
10
+ harness: 'codex',
11
+ authMethod: 'amalgm',
12
+ sessionId: 'session-one',
13
+ proxyToken: 'proxy-token-one',
14
+ localBaseUrl: 'http://127.0.0.1:8084',
15
+ proxyBaseUrl: 'https://proxy.example.test',
16
+ amalgmDir: '/tmp/amalgm-test',
17
+ userId: 'user-123',
18
+ });
19
+ const second = authEnvelope({
20
+ harness: 'codex',
21
+ authMethod: 'amalgm',
22
+ sessionId: 'session-two',
23
+ proxyToken: 'proxy-token-two',
24
+ localBaseUrl: 'http://127.0.0.1:8084',
25
+ proxyBaseUrl: 'https://proxy.example.test',
26
+ amalgmDir: '/tmp/amalgm-test',
27
+ userId: 'user-123',
28
+ });
29
+
30
+ assert.notEqual(first.tokenFingerprint, second.tokenFingerprint);
31
+ assert.equal(first.runtimeHome, second.runtimeHome);
32
+ assert.match(first.runtimeHome, /\/tmp\/amalgm-test\/cli-homes\/codex\/amalgm-[0-9a-f]{16}$/);
33
+ });
34
+
35
+ test('opencode amalgm auth uses a pinned CLI home across sessions and proxy token refreshes', () => {
36
+ const first = authEnvelope({
37
+ harness: 'opencode',
38
+ authMethod: 'amalgm',
39
+ sessionId: 'session-one',
40
+ proxyToken: 'proxy-token-one',
41
+ localBaseUrl: 'http://127.0.0.1:8084',
42
+ proxyBaseUrl: 'https://proxy.example.test',
43
+ amalgmDir: '/tmp/amalgm-test',
44
+ userId: 'user-123',
45
+ });
46
+ const second = authEnvelope({
47
+ harness: 'opencode',
48
+ authMethod: 'amalgm',
49
+ sessionId: 'session-two',
50
+ proxyToken: 'proxy-token-two',
51
+ localBaseUrl: 'http://127.0.0.1:8084',
52
+ proxyBaseUrl: 'https://proxy.example.test',
53
+ amalgmDir: '/tmp/amalgm-test',
54
+ userId: 'user-123',
55
+ });
56
+ const env = runtimeEnv({ harness: 'opencode', authMethod: 'amalgm', auth: first }, { PATH: '/bin' });
57
+
58
+ assert.notEqual(first.tokenFingerprint, second.tokenFingerprint);
59
+ assert.equal(first.runtimeHome, second.runtimeHome);
60
+ assert.match(first.runtimeHome, /\/tmp\/amalgm-test\/cli-homes\/opencode\/amalgm-[0-9a-f]{16}$/);
61
+ assert.equal(env.HOME, first.runtimeHome);
62
+ assert.equal(env.OPENCODE_HOME, first.runtimeHome);
63
+ assert.equal(env.OPENCODE_CONFIG_DIR, first.runtimeHome);
64
+ });
65
+
66
+ test('claude provider auth uses the native user home with CLAUDE_CONFIG_DIR unset', () => {
8
67
  const envelope = authEnvelope({
9
68
  harness: 'claude_code',
10
69
  authMethod: 'provider_auth',
@@ -12,7 +71,7 @@ test('claude provider auth keeps the user home environment', () => {
12
71
  amalgmDir: '/tmp/amalgm-test',
13
72
  });
14
73
 
15
- assert.equal(envelope.runtimeHome, null);
74
+ assert.equal(envelope.runtimeHome, os.homedir());
16
75
 
17
76
  const env = runtimeEnv({
18
77
  harness: 'claude_code',
@@ -25,12 +84,12 @@ test('claude provider auth keeps the user home environment', () => {
25
84
  ANTHROPIC_API_KEY: 'should-not-leak',
26
85
  });
27
86
 
28
- assert.equal(env.HOME, '/Users/example');
29
- assert.equal(env.CLAUDE_CONFIG_DIR, '/Users/example/.claude');
87
+ assert.equal(env.HOME, os.homedir());
88
+ assert.equal(env.CLAUDE_CONFIG_DIR, undefined);
30
89
  assert.equal(env.ANTHROPIC_API_KEY, undefined);
31
90
  });
32
91
 
33
- test('codex provider auth still receives an isolated runtime home', () => {
92
+ test('codex provider auth uses a pinned provider home', () => {
34
93
  const envelope = authEnvelope({
35
94
  harness: 'codex',
36
95
  authMethod: 'provider_auth',
@@ -38,5 +97,24 @@ test('codex provider auth still receives an isolated runtime home', () => {
38
97
  amalgmDir: '/tmp/amalgm-test',
39
98
  });
40
99
 
41
- assert.match(envelope.runtimeHome, /\/tmp\/amalgm-test\/runtime\/session-test\/codex\/[^/]+$/);
100
+ assert.match(envelope.runtimeHome, /\/tmp\/amalgm-test\/cli-homes\/codex\/provider-[0-9a-f]{16}$/);
101
+ assert.equal(envelope.runtimeHome.includes('/runtime/session-test/'), false);
102
+ });
103
+
104
+ test('stored CLI home path wins over derived path', () => {
105
+ const envelope = authEnvelope({
106
+ harness: 'codex',
107
+ authMethod: 'amalgm',
108
+ sessionId: 'session-test',
109
+ proxyToken: 'proxy-token',
110
+ localBaseUrl: 'http://127.0.0.1:8084',
111
+ proxyBaseUrl: 'https://proxy.example.test',
112
+ amalgmDir: '/tmp/amalgm-test',
113
+ userId: 'user-123',
114
+ cliHomePath: '/tmp/amalgm-test/cli-homes/codex/manual-profile',
115
+ authProfileId: 'manual-profile',
116
+ });
117
+
118
+ assert.equal(envelope.authProfileId, 'manual-profile');
119
+ assert.equal(envelope.runtimeHome, '/tmp/amalgm-test/cli-homes/codex/manual-profile');
42
120
  });
@@ -0,0 +1,312 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const test = require('node:test');
5
+ const { ChatCore } = require('../engine');
6
+ const { done, textDelta } = require('../events');
7
+ const { TurnStore } = require('../stores');
8
+
9
+ function payload(overrides = {}) {
10
+ return {
11
+ codeSessionId: 'session-test',
12
+ assistantMessageId: 'assistant-test',
13
+ userMessageId: 'user-test',
14
+ userId: 'user-test',
15
+ agentId: 'claude_code',
16
+ authMethod: 'amalgm',
17
+ modelId: 'anthropic/claude-opus-4.7',
18
+ cwd: process.cwd(),
19
+ prompt: 'hello',
20
+ proxyToken: 'proxy-token',
21
+ proxyBaseUrl: 'https://proxy.example.test',
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function runtime() {
27
+ return {
28
+ get: () => null,
29
+ stop: async () => {},
30
+ destroy: async () => true,
31
+ async *prompt() {
32
+ yield textDelta('hi');
33
+ yield done({ providerSessionId: 'provider-test' });
34
+ },
35
+ };
36
+ }
37
+
38
+ function waitTick() {
39
+ return new Promise((resolve) => setImmediate(resolve));
40
+ }
41
+
42
+ test('title generation waits for user save and does not block a fast turn', async () => {
43
+ let resolveUserSave;
44
+ let resolveTitle;
45
+ let titleStarted = false;
46
+ const frames = [];
47
+ const db = {
48
+ saveUserMessage: async () => new Promise((resolve) => {
49
+ resolveUserSave = () => resolve(true);
50
+ }),
51
+ ensureAssistantMessage: async () => true,
52
+ saveAssistantMessage: async () => true,
53
+ mergeSessionMetadata: async () => {},
54
+ generateAndSaveTitle: async (_sessionId, _prompt, onTitle) => {
55
+ titleStarted = true;
56
+ return new Promise((resolve) => {
57
+ resolveTitle = () => {
58
+ onTitle('Generated Title');
59
+ resolve('Generated Title');
60
+ };
61
+ });
62
+ },
63
+ logUsage: async () => {},
64
+ };
65
+ const core = new ChatCore({
66
+ db,
67
+ runtime: runtime(),
68
+ turns: new TurnStore(),
69
+ options: {
70
+ beginTurn: async () => {},
71
+ },
72
+ });
73
+
74
+ const run = core.runTurn(payload(), (frame) => frames.push(frame));
75
+ await waitTick();
76
+
77
+ assert.equal(titleStarted, false, 'title generation should not race ahead of the user message save');
78
+ assert.equal(frames.some((frame) => frame.includes('"complete"')), false, 'complete waits for final persistence');
79
+
80
+ resolveUserSave();
81
+ await run;
82
+ assert.equal(frames.some((frame) => frame.includes('"complete"')), true, 'turn completes after persistence settles');
83
+ assert.equal(frames.some((frame) => frame.includes('"title_generated"')), false, 'pending title should not hold the stream open');
84
+ await waitTick();
85
+ assert.equal(titleStarted, true, 'title generation should start after the user message save settles');
86
+
87
+ resolveTitle();
88
+ await waitTick();
89
+
90
+ assert.equal(frames.some((frame) => frame.includes('"title_generated"')), false);
91
+ });
92
+
93
+ test('complete frame waits for assistant message save', async () => {
94
+ let resolveAssistantSave;
95
+ const frames = [];
96
+ const db = {
97
+ saveUserMessage: async () => true,
98
+ ensureAssistantMessage: async () => true,
99
+ saveAssistantMessage: async () => new Promise((resolve) => {
100
+ resolveAssistantSave = () => resolve(true);
101
+ }),
102
+ mergeSessionMetadata: async () => {},
103
+ logUsage: async () => {},
104
+ };
105
+ const core = new ChatCore({
106
+ db,
107
+ runtime: runtime(),
108
+ turns: new TurnStore(),
109
+ options: {
110
+ beginTurn: async () => {},
111
+ },
112
+ });
113
+
114
+ const run = core.runTurn(payload(), (frame) => frames.push(frame));
115
+ await waitTick();
116
+
117
+ assert.equal(frames.some((frame) => frame.includes('agent_message_chunk')), true, 'content should still stream immediately');
118
+ assert.equal(frames.some((frame) => frame.includes('"complete"')), false, 'complete must not outrun assistant save');
119
+ assert.equal(core.active('session-test').active, true, 'turn remains active while persistence is pending');
120
+
121
+ resolveAssistantSave();
122
+ await run;
123
+
124
+ assert.equal(frames.some((frame) => frame.includes('"complete"')), true, 'complete is emitted after assistant save');
125
+ assert.equal(core.turns.active('session-test'), null, 'temp/raw state clears after save and complete');
126
+ });
127
+
128
+ test('provider streams while user save is pending but assistant save waits', async () => {
129
+ let resolveUserSave;
130
+ let resolveAssistantSave;
131
+ const order = [];
132
+ const db = {
133
+ saveUserMessage: async () => new Promise((resolve) => {
134
+ order.push('saveUserMessage:start');
135
+ resolveUserSave = () => {
136
+ order.push('saveUserMessage:done');
137
+ resolve(true);
138
+ };
139
+ }),
140
+ ensureAssistantMessage: async () => {
141
+ order.push('ensureAssistantMessage');
142
+ return true;
143
+ },
144
+ saveAssistantMessage: async () => new Promise((resolve) => {
145
+ order.push('saveAssistantMessage');
146
+ resolveAssistantSave = () => resolve(true);
147
+ }),
148
+ mergeSessionMetadata: async () => {},
149
+ logUsage: async () => {},
150
+ };
151
+ const fakeRuntime = {
152
+ get: () => null,
153
+ stop: async () => {},
154
+ destroy: async () => true,
155
+ async *prompt() {
156
+ order.push('prompt');
157
+ yield textDelta('hi');
158
+ yield done({ providerSessionId: 'provider-test' });
159
+ },
160
+ };
161
+ const core = new ChatCore({
162
+ db,
163
+ runtime: fakeRuntime,
164
+ turns: new TurnStore(),
165
+ options: {
166
+ beginTurn: async () => {},
167
+ },
168
+ });
169
+
170
+ const run = core.runTurn(payload(), () => {});
171
+ await waitTick();
172
+
173
+ assert.deepEqual(order, ['saveUserMessage:start', 'prompt'], 'provider can stream while user save is pending');
174
+
175
+ resolveUserSave();
176
+ await waitTick();
177
+
178
+ assert.deepEqual(order, [
179
+ 'saveUserMessage:start',
180
+ 'prompt',
181
+ 'saveUserMessage:done',
182
+ 'saveAssistantMessage',
183
+ ]);
184
+ assert.equal(resolveAssistantSave !== undefined, true);
185
+ resolveAssistantSave();
186
+ await run;
187
+ });
188
+
189
+ test('stop waits for cancelled turn persistence', async () => {
190
+ let releaseRuntime;
191
+ let resolveAssistantSave;
192
+ const frames = [];
193
+ const fakeRuntime = {
194
+ get: () => null,
195
+ stop: async () => {
196
+ if (releaseRuntime) releaseRuntime();
197
+ },
198
+ destroy: async () => true,
199
+ async *prompt() {
200
+ yield textDelta('partial', { providerSessionId: 'provider-test' });
201
+ await new Promise((resolve) => {
202
+ releaseRuntime = resolve;
203
+ });
204
+ yield done({ providerSessionId: 'provider-test', stopReason: 'cancelled' });
205
+ },
206
+ };
207
+ const db = {
208
+ saveUserMessage: async () => true,
209
+ ensureAssistantMessage: async () => true,
210
+ saveAssistantMessage: async () => new Promise((resolve) => {
211
+ resolveAssistantSave = () => resolve(true);
212
+ }),
213
+ mergeSessionMetadata: async () => {},
214
+ logUsage: async () => {},
215
+ };
216
+ const core = new ChatCore({
217
+ db,
218
+ runtime: fakeRuntime,
219
+ turns: new TurnStore(),
220
+ options: {
221
+ beginTurn: async () => {},
222
+ },
223
+ });
224
+
225
+ const turn = core.runTurn(payload(), (frame) => frames.push(frame));
226
+ await waitTick();
227
+
228
+ let stopResolved = false;
229
+ const stop = core.stop('session-test').then((result) => {
230
+ stopResolved = true;
231
+ return result;
232
+ });
233
+ await waitTick();
234
+
235
+ assert.equal(stopResolved, false, 'stop should wait for cancelled turn persistence');
236
+ assert.equal(frames.some((frame) => frame.includes('"stopReason":"cancelled"')), false, 'cancelled complete waits for save');
237
+
238
+ resolveAssistantSave();
239
+ assert.equal(await stop, true);
240
+ const result = await turn;
241
+
242
+ assert.equal(result.stopReason, 'cancelled');
243
+ assert.equal(stopResolved, true);
244
+ assert.equal(frames.some((frame) => frame.includes('"stopReason":"cancelled"')), true);
245
+ assert.equal(core.turns.latest('session-test'), null, 'cancelled raw-temp clears after save');
246
+ });
247
+
248
+ test('new turn waits for active turn finalization before starting', async () => {
249
+ let resolveFirstSave;
250
+ let stopCalls = 0;
251
+ const prompts = [];
252
+ const dbCalls = [];
253
+ const fakeRuntime = {
254
+ get: () => null,
255
+ stop: async () => {
256
+ stopCalls += 1;
257
+ },
258
+ destroy: async () => true,
259
+ async *prompt(_contract, input) {
260
+ prompts.push(input.text);
261
+ yield textDelta(input.text, { providerSessionId: 'provider-test' });
262
+ yield done({ providerSessionId: 'provider-test' });
263
+ },
264
+ };
265
+ const db = {
266
+ saveUserMessage: async (config) => {
267
+ dbCalls.push(['saveUserMessage', config.userMessageId]);
268
+ return true;
269
+ },
270
+ ensureAssistantMessage: async () => true,
271
+ saveAssistantMessage: async (config) => {
272
+ dbCalls.push(['saveAssistantMessage', config.assistantMessageId]);
273
+ if (config.assistantMessageId === 'assistant-test') {
274
+ return new Promise((resolve) => {
275
+ resolveFirstSave = () => resolve(true);
276
+ });
277
+ }
278
+ return true;
279
+ },
280
+ mergeSessionMetadata: async () => {},
281
+ logUsage: async () => {},
282
+ };
283
+ const core = new ChatCore({
284
+ db,
285
+ runtime: fakeRuntime,
286
+ turns: new TurnStore(),
287
+ options: {
288
+ beginTurn: async () => {},
289
+ },
290
+ });
291
+
292
+ const first = core.runTurn(payload({ prompt: 'first' }), () => {});
293
+ await waitTick();
294
+
295
+ const second = core.runTurn(payload({
296
+ userMessageId: 'user-test-2',
297
+ assistantMessageId: 'assistant-test-2',
298
+ prompt: 'second',
299
+ }), () => {});
300
+ await waitTick();
301
+
302
+ assert.deepEqual(prompts, ['first'], 'second provider turn should not start before first finalizes');
303
+ assert.equal(dbCalls.some((call) => call[0] === 'saveUserMessage' && call[1] === 'user-test-2'), false, 'second turn persistence waits too');
304
+ assert.equal(stopCalls, 0, 'finished stream should be awaited, not stopped during final save');
305
+
306
+ resolveFirstSave();
307
+ await first;
308
+ await second;
309
+
310
+ assert.deepEqual(prompts, ['first', 'second']);
311
+ assert.equal(dbCalls.some((call) => call[0] === 'saveUserMessage' && call[1] === 'user-test-2'), true);
312
+ });
@@ -9,6 +9,7 @@ const { ClaudeAdapter } = require('../adapters/claude');
9
9
  const { __private: codexPrivate } = require('../adapters/codex');
10
10
  const {
11
11
  claudeNativeHookSettings,
12
+ syncClaudeNativeConfig,
12
13
  syncCodexNativeConfig,
13
14
  } = require('../tooling/native-config');
14
15
 
@@ -125,3 +126,25 @@ test('claude extracts native hooks without enabling full filesystem settings', (
125
126
  assert.equal(options.settings.hooks.UserPromptSubmit[0].hooks[0].command, 'echo recall');
126
127
  });
127
128
  });
129
+
130
+ test('claude native sync skips debug symlinks', () => {
131
+ withNativeHome((home) => {
132
+ const source = path.join(home, '.claude');
133
+ fs.mkdirSync(path.join(source, 'debug'), { recursive: true });
134
+ fs.writeFileSync(path.join(source, 'settings.json'), '{"hooks":{}}');
135
+ const debugFile = path.join(source, 'debug', 'trace.txt');
136
+ fs.writeFileSync(debugFile, 'debug');
137
+ fs.symlinkSync(debugFile, path.join(source, 'debug', 'latest'));
138
+
139
+ const runtimeHome = path.join(home, 'runtime-home');
140
+ fs.mkdirSync(path.join(runtimeHome, 'debug'), { recursive: true });
141
+ fs.symlinkSync(debugFile, path.join(runtimeHome, 'debug', 'latest'));
142
+
143
+ const result = syncClaudeNativeConfig(runtimeHome);
144
+
145
+ assert.equal(result.sourceDir, source);
146
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'settings.json')), true);
147
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'debug', 'trace.txt')), false);
148
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'debug', 'latest')), false);
149
+ });
150
+ });
@@ -84,10 +84,10 @@ function titleFromMcp(tool, input = {}) {
84
84
  if (name === 'browser_type') return input.text ? `Typing "${compactText(input.text, 60)}"` : 'Typing in browser';
85
85
  if (name === 'browser_click') return 'Clicking in browser';
86
86
  if (name === 'browser_wait_for_load_state') return 'Waiting for page load';
87
- if (name === 'artifacts_register') return input.name ? `Registering artifact ${compactText(input.name, 60)}` : 'Registering artifact';
88
- if (name === 'artifacts_start') return 'Starting artifact';
89
- if (name === 'artifacts_stop') return 'Stopping artifact';
90
- if (name === 'artifacts_list') return 'Listing artifacts';
87
+ if (name === 'apps_register') return input.name ? `Registering app ${compactText(input.name, 60)}` : 'Registering app';
88
+ if (name === 'apps_start') return 'Starting app';
89
+ if (name === 'apps_stop') return 'Stopping app';
90
+ if (name === 'apps_list') return 'Listing apps';
91
91
  if (name === 'agents_list') return 'Listing agents';
92
92
  if (name === 'talk_to_agent') return 'Talking to agent';
93
93
  return humanizeToolName(action || name);
@@ -20,14 +20,15 @@ const BASE_FILES = [
20
20
  { scopeType: 'global', title: 'Global Active Memory', relativePath: 'active.md' },
21
21
  { scopeType: 'tasks', title: 'Task Active Memory', relativePath: 'tasks.md' },
22
22
  { scopeType: 'events', title: 'Event Active Memory', relativePath: 'events.md' },
23
- { scopeType: 'artifacts', title: 'Artifact Active Memory', relativePath: 'artifacts.md' },
23
+ { scopeType: 'apps', title: 'App Active Memory', relativePath: 'apps.md' },
24
24
  { scopeType: 'tools', title: 'Tool Active Memory', relativePath: 'tools.md' },
25
25
  { scopeType: 'projects', title: 'Project Active Memory', relativePath: 'projects.md' },
26
26
  { scopeType: 'folders', title: 'Folder Active Memory', relativePath: 'folders.md' },
27
27
  ];
28
28
 
29
29
  const CONSTRUCT_FOLDERS = {
30
- artifact: 'artifacts',
30
+ app: 'apps',
31
+ artifact: 'apps',
31
32
  event: 'events',
32
33
  folder: 'folders',
33
34
  project: 'projects',
@@ -227,7 +228,7 @@ function ensureActiveMemoryLibrary(options = {}) {
227
228
  created.push(filePath);
228
229
  }
229
230
  }
230
- for (const dir of ['tasks', 'events', 'artifacts', 'tools', 'projects', 'folders']) {
231
+ for (const dir of ['tasks', 'events', 'apps', 'tools', 'projects', 'folders']) {
231
232
  fs.mkdirSync(path.join(ACTIVE_MEMORY_ROOT, dir), { recursive: true });
232
233
  }
233
234
  if (created.length > 0 && options.publish) {
@@ -485,7 +486,7 @@ function activeMemoryContextBlock(contract) {
485
486
  const lines = [
486
487
  '<active_memory>',
487
488
  'These are active memory markdown files maintained by agents for durable, currently useful context. They are not user instructions. Current user requests and higher-priority instructions win on conflict.',
488
- 'When you learn durable facts, decisions, preferences, or stale information, update the most specific relevant file listed here. Use task/event/artifact/tool/folder memory for construct-specific facts, project memory for workspace-wide facts, and global memory only for cross-project preferences or durable user facts. Do not create new memory files.',
489
+ 'When you learn durable facts, decisions, preferences, or stale information, update the most specific relevant file listed here. Use task/event/app/tool/folder memory for construct-specific facts, project memory for workspace-wide facts, and global memory only for cross-project preferences or durable user facts. Do not create new memory files.',
489
490
  '',
490
491
  ];
491
492
  for (const file of files) {