gestalt-mobile 0.16.2 → 0.16.4

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.
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
16
16
  <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
17
  <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
18
  <title>Gestalt Mobile</title>
19
- <script type="module" crossorigin src="/assets/index-DT1VwqSF.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-BotpWcLh.css">
19
+ <script type="module" crossorigin src="/assets/index-bKzoif3G.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-D9v-aHYI.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -162,7 +162,7 @@ export async function composeRelayApp(options) {
162
162
  }
163
163
  events.publish(journal.append(sessionId, 'plan.updated', { plan: update.plan, reason: update.reason }, occurredAt));
164
164
  }
165
- }, options.planMeasurementBaseUrl)
165
+ }, options.planMeasurementBaseUrl, 30_000, 64, root)
166
166
  : null;
167
167
  if (runtime && planMeasurementHelperPath) {
168
168
  planMeasurementRefresh = new PlanMeasurementRefresh(async (sessionId) => {
@@ -189,7 +189,14 @@ export async function composeRelayApp(options) {
189
189
  if (session)
190
190
  saveSession(RelaySession.rehydrate(session).requireAttention(new Date().toISOString()).snapshot);
191
191
  });
192
- recoverExitedSession = (sessionId) => supervisor.recover(sessionId);
192
+ // An exited child has released our writer. Do not repeatedly resume a
193
+ // durable thread: another Codex client may acquire it between callbacks.
194
+ recoverExitedSession = (sessionId) => {
195
+ supervisor.cancel(sessionId);
196
+ const session = sessions.find(sessionId);
197
+ if (session)
198
+ saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
199
+ };
193
200
  }
194
201
  let authorization;
195
202
  if (passkeyAuthEnabled) {
@@ -283,8 +290,12 @@ export async function composeRelayApp(options) {
283
290
  ? async (session, settings) => runtime.start(session, new Date().toISOString(), settings)
284
291
  : undefined,
285
292
  startTurn: runtime
286
- ? async (session, text) => runtime.startTurn(session, text, new Date().toISOString())
293
+ ? async (session, text, clientUserMessageId) => runtime.startTurn(session, text, clientUserMessageId, new Date().toISOString())
294
+ : undefined,
295
+ ensureWriter: runtime
296
+ ? (session) => runtime.ensureWriter(session, new Date().toISOString())
287
297
  : undefined,
298
+ releaseWriter: runtime ? (id) => runtime.release(id) : undefined,
288
299
  onTurnStarted: (session) => planMeasurementRefresh?.refreshNow(session.id),
289
300
  models,
290
301
  readHistory: runtime ? (session) => runtime.readHistory(session) : undefined,
@@ -301,7 +312,7 @@ export async function composeRelayApp(options) {
301
312
  now: () => new Date().toISOString(),
302
313
  list: () => sessions.list(),
303
314
  save: saveSession,
304
- restore: (session) => runtime.restore(session, new Date().toISOString()),
315
+ read: (session) => runtime.readHistory(session),
305
316
  })
306
317
  : undefined,
307
318
  release: (session) => RelaySession.rehydrate(session).release(new Date().toISOString()).snapshot,
@@ -402,25 +413,18 @@ export async function composeRelayApp(options) {
402
413
  database.close();
403
414
  throw error;
404
415
  }
405
- const restoreActiveSessions = async () => {
406
- if (!runtime)
407
- return;
408
- await mapWithConcurrency(sessions
409
- .list()
410
- .filter((session) => session.desiredState === 'active' && session.threadId !== null), 2, async (session) => {
411
- try {
412
- saveSession(await runtime.restore(session, new Date().toISOString()));
413
- }
414
- catch {
415
- saveSession(RelaySession.rehydrate(session).requireAttention(new Date().toISOString()).snapshot);
416
- }
416
+ const detachActiveSessions = async () => {
417
+ await mapWithConcurrency(sessions.list().filter((session) => session.threadId !== null), 2, async (session) => {
418
+ if (session.desiredState === 'active')
419
+ saveSession(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
420
+ await runtime?.watchPlanStatus(session);
417
421
  });
418
422
  };
419
423
  app.addHook('onListen', async () => {
420
424
  const profile = (await options.profiles.list()).find((item) => item.state === 'ok')?.name;
421
425
  if (profile)
422
426
  await editorSkillCatalog.refresh(profile, root);
423
- await restoreActiveSessions();
427
+ await detachActiveSessions();
424
428
  });
425
429
  app.addHook('onClose', async () => {
426
430
  planMeasurementRefresh?.stopAll();
@@ -0,0 +1,59 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export class WriterAcquisitionError extends Error {
7
+ kind;
8
+ constructor(kind) {
9
+ super(kind);
10
+ this.kind = kind;
11
+ this.name = 'WriterAcquisitionError';
12
+ }
13
+ }
14
+ export function writerAcquisitionProblem(kind) {
15
+ switch (kind) {
16
+ case 'writerBusy':
17
+ return {
18
+ status: 409,
19
+ code: 'SESSION_WRITER_BUSY',
20
+ retryable: true,
21
+ detail: 'This thread is active in another Codex client. Release it there, then retry.',
22
+ };
23
+ case 'rolloutMissing':
24
+ return {
25
+ status: 409,
26
+ code: 'SESSION_ROLLOUT_MISSING',
27
+ retryable: false,
28
+ detail: 'This stored thread is no longer available.',
29
+ };
30
+ case 'workspaceUnavailable':
31
+ return {
32
+ status: 409,
33
+ code: 'SESSION_WORKSPACE_UNAVAILABLE',
34
+ retryable: true,
35
+ detail: 'The session workspace is unavailable.',
36
+ };
37
+ case 'runtimeDependencyFailed':
38
+ return {
39
+ status: 502,
40
+ code: 'SESSION_RUNTIME_DEPENDENCY_FAILED',
41
+ retryable: true,
42
+ detail: 'A required Codex runtime dependency is unavailable.',
43
+ };
44
+ case 'protocolIncompatible':
45
+ return {
46
+ status: 503,
47
+ code: 'CODEX_PROTOCOL_INCOMPATIBLE',
48
+ retryable: false,
49
+ detail: 'The installed Codex runtime is incompatible with this relay.',
50
+ };
51
+ case 'runtimeUnavailable':
52
+ return {
53
+ status: 503,
54
+ code: 'SESSION_RUNTIME_UNAVAILABLE',
55
+ retryable: true,
56
+ detail: 'The Codex runtime is unavailable. Retry shortly.',
57
+ };
58
+ }
59
+ }
@@ -34,7 +34,16 @@ function toChatItem(item, timestamp, turnId) {
34
34
  .join('\n')
35
35
  : '';
36
36
  return text
37
- ? [{ id, kind: 'user', text, ...owner, ...(timestamp ? { occurredAt: timestamp } : {}) }]
37
+ ? [
38
+ {
39
+ id,
40
+ kind: 'user',
41
+ text,
42
+ ...owner,
43
+ ...(typeof item.clientId === 'string' ? { operationId: item.clientId } : {}),
44
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
45
+ },
46
+ ]
38
47
  : [];
39
48
  }
40
49
  case 'agentMessage':
@@ -48,7 +57,7 @@ function toChatItem(item, timestamp, turnId) {
48
57
  ...(item.phase === 'commentary' || item.phase === 'final_answer'
49
58
  ? { phase: item.phase }
50
59
  : {}),
51
- ...(timestamp ? { occurredAt: timestamp } : {}),
60
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
52
61
  },
53
62
  ]
54
63
  : [];
@@ -56,10 +65,28 @@ function toChatItem(item, timestamp, turnId) {
56
65
  if (!Array.isArray(item.summary))
57
66
  return [];
58
67
  const summary = reasoningSummary(item.summary);
59
- return summary.length ? [{ id, kind: 'reasoning', summary, ...owner }] : [];
68
+ return summary.length
69
+ ? [
70
+ {
71
+ id,
72
+ kind: 'reasoning',
73
+ summary,
74
+ ...owner,
75
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
76
+ },
77
+ ]
78
+ : [];
60
79
  case 'plan':
61
80
  return typeof item.text === 'string'
62
- ? [{ id, kind: 'plan', text: item.text, ...owner }]
81
+ ? [
82
+ {
83
+ id,
84
+ kind: 'plan',
85
+ text: item.text,
86
+ ...owner,
87
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
88
+ },
89
+ ]
63
90
  : [];
64
91
  case 'commandExecution':
65
92
  return typeof item.command === 'string' && typeof item.status === 'string'
@@ -70,6 +97,7 @@ function toChatItem(item, timestamp, turnId) {
70
97
  command: item.command,
71
98
  status: item.status,
72
99
  ...owner,
100
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
73
101
  ...(typeof item.exitCode === 'number' ? { exitCode: item.exitCode } : {}),
74
102
  },
75
103
  ]
@@ -79,9 +107,32 @@ function toChatItem(item, timestamp, turnId) {
79
107
  return [];
80
108
  const paths = item.changes.flatMap((change) => isRecord(change) && typeof change.path === 'string' ? [change.path] : []);
81
109
  return paths.length
82
- ? [{ id, kind: 'fileChange', paths, status: item.status, ...owner }]
110
+ ? [
111
+ {
112
+ id,
113
+ kind: 'fileChange',
114
+ paths,
115
+ status: item.status,
116
+ ...owner,
117
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
118
+ },
119
+ ]
83
120
  : [];
84
121
  }
122
+ case 'mcpToolCall':
123
+ case 'dynamicToolCall':
124
+ return typeof item.tool === 'string' && typeof item.status === 'string'
125
+ ? [
126
+ {
127
+ id,
128
+ kind: 'tool',
129
+ name: item.tool,
130
+ status: item.status,
131
+ ...owner,
132
+ ...(timestamp !== undefined ? { occurredAt: timestamp } : {}),
133
+ },
134
+ ]
135
+ : [];
85
136
  default:
86
137
  return [];
87
138
  }
@@ -6,6 +6,14 @@
6
6
  import { DomainError } from './errors.js';
7
7
  import { createSkillSelection, normalizeSkillProfileName, } from '../../skills/model/skill-profile.js';
8
8
  import { interactionId, profileName, sessionId, threadId, turnId, workspaceId, workspacePath, } from './value-objects.js';
9
+ /** A durable thread can be read even when this relay has no live writer. */
10
+ export function isSessionReadable(session) {
11
+ return session.threadId !== null;
12
+ }
13
+ /** Writer ownership is deliberately transient: only a live relay state implies it. */
14
+ export function relayOwnsWriter(session) {
15
+ return session.state === 'ready' || session.state === 'turnActive';
16
+ }
9
17
  export function createEffectiveSkillSelection(input) {
10
18
  return {
11
19
  ...(input.selectedProfileName === undefined
@@ -4,6 +4,7 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { z } from 'zod';
7
+ import { RecentThreadHistoryUnavailable } from './use-case.js';
7
8
  const requestSchema = z.object({
8
9
  threadId: z.string().min(1),
9
10
  cwd: z.string().startsWith('/'),
@@ -20,9 +21,14 @@ export function registerPromoteRecentThread(app, deps) {
20
21
  return reply.code(202).send(await deps.promote(thread));
21
22
  }
22
23
  catch (error) {
24
+ const historyUnavailable = error instanceof RecentThreadHistoryUnavailable;
23
25
  return reply.code(409).send({
24
- code: 'RECENT_THREAD_OPEN_FAILED',
25
- detail: error instanceof Error ? error.message : 'Could not open the recent session.',
26
+ code: historyUnavailable
27
+ ? 'RECENT_THREAD_HISTORY_UNAVAILABLE'
28
+ : 'RECENT_THREAD_OPEN_FAILED',
29
+ detail: historyUnavailable
30
+ ? 'The selected thread history is currently unavailable. Retry after Codex is available.'
31
+ : 'The selected thread could not be opened. Retry shortly.',
26
32
  });
27
33
  }
28
34
  });
@@ -4,10 +4,38 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { RelaySession } from '../model/relay-session.js';
7
+ export class RecentThreadHistoryUnavailable extends Error {
8
+ constructor() {
9
+ super('RECENT_THREAD_HISTORY_UNAVAILABLE');
10
+ }
11
+ }
12
+ const inFlightPromotions = new Map();
7
13
  export async function promoteRecentThread(thread, deps) {
14
+ const key = `${thread.profile}:${thread.cwd}:${thread.id}`;
15
+ const ongoing = inFlightPromotions.get(key);
16
+ if (ongoing)
17
+ return ongoing;
18
+ const operation = promote(thread, deps);
19
+ inFlightPromotions.set(key, operation);
20
+ try {
21
+ return await operation;
22
+ }
23
+ finally {
24
+ if (inFlightPromotions.get(key) === operation)
25
+ inFlightPromotions.delete(key);
26
+ }
27
+ }
28
+ async function promote(thread, deps) {
8
29
  const existing = deps.list().find((session) => session.threadId === thread.id);
9
- if (existing?.state === 'ready' || existing?.state === 'turnActive')
30
+ if (existing) {
31
+ try {
32
+ await deps.read(existing);
33
+ }
34
+ catch {
35
+ throw new RecentThreadHistoryUnavailable();
36
+ }
10
37
  return existing;
38
+ }
11
39
  const now = deps.now();
12
40
  const imported = existing ??
13
41
  RelaySession.fromExistingThread({
@@ -18,9 +46,13 @@ export async function promoteRecentThread(thread, deps) {
18
46
  threadId: thread.id,
19
47
  now,
20
48
  }).snapshot;
21
- if (!existing)
22
- deps.save(imported);
23
- const active = await deps.restore(imported);
24
- deps.save(active);
25
- return active;
49
+ // Validate first: an unavailable thread must never leave an imported stub.
50
+ try {
51
+ await deps.read(imported);
52
+ }
53
+ catch {
54
+ throw new RecentThreadHistoryUnavailable();
55
+ }
56
+ deps.save(imported);
57
+ return imported;
26
58
  }
@@ -60,6 +60,8 @@ export function registerSessionRoutes(app, deps) {
60
60
  registerStartTurn(app, {
61
61
  find: sessions.find,
62
62
  start: sessions.startTurn,
63
+ ensureWriter: sessions.ensureWriter,
64
+ releaseWriter: sessions.releaseWriter,
63
65
  save: sessions.save,
64
66
  onStarted: sessions.onTurnStarted,
65
67
  idempotency: sessions.idempotency,
@@ -4,8 +4,11 @@
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
6
  import { createHash } from 'node:crypto';
7
+ import { RelaySession } from '../model/relay-session.js';
8
+ import { WriterAcquisitionError, writerAcquisitionProblem, } from '../application/writer-acquisition.js';
7
9
  export function registerStartTurn(app, deps) {
8
10
  const inflight = new Map();
11
+ const sessionOperations = new Map();
9
12
  // Durable idempotency results are session-scoped and replay for as long as the
10
13
  // store retains them; a reused key with a different prompt is rejected.
11
14
  // Text is capped at 100,000 characters; leave room for JSON encoding only.
@@ -16,7 +19,7 @@ export function registerStartTurn(app, deps) {
16
19
  const text = request.body.text?.trim() ?? '';
17
20
  if (text.length > 100_000)
18
21
  return reply.code(400).send({ code: 'TURN_INPUT_TOO_LONG' });
19
- if (!text || session.state !== 'ready')
22
+ if (!text)
20
23
  return reply.code(409).send({ code: 'SESSION_NOT_READY' });
21
24
  const key = request.headers['idempotency-key'];
22
25
  if (typeof key === 'string' && key && deps.idempotency) {
@@ -28,15 +31,31 @@ export function registerStartTurn(app, deps) {
28
31
  const inflightKey = `${scope}:${key}`;
29
32
  const operation = inflight.get(inflightKey) ??
30
33
  (async () => {
31
- const started = await deps.start(session, text);
32
- deps.save(started);
33
- deps.onStarted?.(started);
34
- const result = {
35
- statusCode: 202,
36
- body: JSON.stringify({ fingerprint, response: started }),
37
- };
38
- deps.idempotency.put(scope, key, result.statusCode, result.body);
39
- return result;
34
+ return serializeSession(sessionOperations, session.id, async () => {
35
+ const current = deps.find(session.id);
36
+ if (!current)
37
+ return { statusCode: 404, body: JSON.stringify({ code: 'SESSION_NOT_FOUND' }) };
38
+ const durable = deps.idempotency.get(scope, key);
39
+ if (durable)
40
+ return durable;
41
+ if (current.state === 'turnActive')
42
+ return { statusCode: 409, body: JSON.stringify({ code: 'SESSION_TURN_ACTIVE' }) };
43
+ let started;
44
+ try {
45
+ started = await startWithWriter(deps, current, text, key);
46
+ }
47
+ catch (error) {
48
+ if (error instanceof StartTurnProblem)
49
+ return error.result;
50
+ throw error;
51
+ }
52
+ const result = {
53
+ statusCode: 202,
54
+ body: JSON.stringify({ fingerprint, response: started }),
55
+ };
56
+ deps.idempotency.put(scope, key, result.statusCode, result.body);
57
+ return result;
58
+ });
40
59
  })();
41
60
  inflight.set(inflightKey, operation);
42
61
  try {
@@ -46,14 +65,113 @@ export function registerStartTurn(app, deps) {
46
65
  inflight.delete(inflightKey);
47
66
  }
48
67
  }
49
- const started = await deps.start(session, text);
68
+ return serializeSession(sessionOperations, session.id, async () => {
69
+ const current = deps.find(session.id);
70
+ if (!current)
71
+ return reply.code(404).send({ code: 'SESSION_NOT_FOUND' });
72
+ if (current.state === 'turnActive')
73
+ return reply.code(409).send({ code: 'SESSION_TURN_ACTIVE' });
74
+ try {
75
+ return reply
76
+ .code(202)
77
+ .send(await startWithWriter(deps, current, text, typeof key === 'string' ? key : undefined));
78
+ }
79
+ catch (error) {
80
+ if (error instanceof StartTurnProblem)
81
+ return reply.code(error.result.statusCode).send(JSON.parse(error.result.body));
82
+ throw error;
83
+ }
84
+ });
85
+ });
86
+ }
87
+ async function startWithWriter(deps, session, text, key) {
88
+ const writable = await ensureWriter(deps, session);
89
+ if ('body' in writable)
90
+ throw new StartTurnProblem(writable);
91
+ try {
92
+ if (writable.session !== session)
93
+ deps.save(writable.session);
94
+ }
95
+ catch (error) {
96
+ await cleanupAcquiredWriter(deps, writable.session, session);
97
+ throw error;
98
+ }
99
+ try {
100
+ const started = await deps.start(writable.session, text, key);
50
101
  deps.save(started);
51
102
  deps.onStarted?.(started);
52
- return reply.code(202).send(started);
103
+ return started;
104
+ }
105
+ catch (error) {
106
+ if (writable.session !== session)
107
+ await cleanupAcquiredWriter(deps, writable.session, session);
108
+ throw error;
109
+ }
110
+ }
111
+ class StartTurnProblem extends Error {
112
+ result;
113
+ constructor(result) {
114
+ super('START_TURN_PROBLEM');
115
+ this.result = result;
116
+ }
117
+ }
118
+ async function serializeSession(operations, sessionId, operation) {
119
+ const prior = operations.get(sessionId) ?? Promise.resolve();
120
+ let release;
121
+ const current = new Promise((resolve) => {
122
+ release = resolve;
53
123
  });
124
+ operations.set(sessionId, current);
125
+ await prior;
126
+ try {
127
+ return await operation();
128
+ }
129
+ finally {
130
+ release();
131
+ if (operations.get(sessionId) === current)
132
+ operations.delete(sessionId);
133
+ }
134
+ }
135
+ /** Cleanup is best effort: never replace the original start/persistence error. */
136
+ async function cleanupAcquiredWriter(deps, acquired, original) {
137
+ try {
138
+ await deps.releaseWriter?.(acquired.id);
139
+ }
140
+ catch {
141
+ // Process loss is already a valid release outcome.
142
+ }
143
+ try {
144
+ deps.save(original);
145
+ }
146
+ catch {
147
+ // The original I/O failure remains the observable failure.
148
+ }
149
+ }
150
+ async function ensureWriter(deps, session) {
151
+ if (!deps.ensureWriter) {
152
+ if (session.state === 'ready')
153
+ return { session, replacementCreated: false };
154
+ return failure('runtimeUnavailable');
155
+ }
156
+ try {
157
+ return await deps.ensureWriter(session);
158
+ }
159
+ catch (error) {
160
+ if (error instanceof WriterAcquisitionError &&
161
+ error.kind === 'writerBusy' &&
162
+ session.state !== 'stopped')
163
+ deps.save(RelaySession.rehydrate(session).stop(new Date().toISOString()).snapshot);
164
+ return failure(error instanceof WriterAcquisitionError ? error.kind : 'runtimeUnavailable');
165
+ }
166
+ }
167
+ function failure(kind) {
168
+ const problem = writerAcquisitionProblem(kind);
169
+ return { statusCode: problem.status, body: JSON.stringify(problem) };
54
170
  }
55
171
  function replay(result, fingerprint, reply) {
56
172
  const cached = JSON.parse(result.body);
173
+ if (cached.fingerprint === undefined)
174
+ return reply.code(result.statusCode).send(cached);
57
175
  if (cached.fingerprint !== fingerprint)
58
176
  return reply.code(409).send({ code: 'IDEMPOTENCY_KEY_REUSED' });
59
177
  return reply.code(result.statusCode).send(cached.response);
@@ -6,6 +6,7 @@
6
6
  import { createInterface } from 'node:readline';
7
7
  export const CODEX_THREAD_NOT_FOUND = 'CODEX_THREAD_NOT_FOUND';
8
8
  export const CODEX_JSON_RPC_ERROR = 'CODEX_JSON_RPC_ERROR';
9
+ export const CODEX_THREAD_WRITER_BUSY = 'CODEX_THREAD_WRITER_BUSY';
9
10
  /** A bounded representation of an app-server JSON-RPC failure. */
10
11
  export class CodexJsonRpcError extends Error {
11
12
  code;
@@ -25,10 +26,19 @@ export class CodexJsonRpcError extends Error {
25
26
  export function isMissingCodexThreadRollout(error) {
26
27
  return error instanceof CodexJsonRpcError && error.kind === CODEX_THREAD_NOT_FOUND;
27
28
  }
29
+ /** Compatibility shim for the confirmed Codex 0.146 active-writer response. */
30
+ export function isCodexThreadWriterBusy(error) {
31
+ return error instanceof CodexJsonRpcError && error.kind === CODEX_THREAD_WRITER_BUSY;
32
+ }
28
33
  function classifyCodexJsonRpcError(code, message) {
29
- return code === -32600 && /^no rollout found for thread id\b/i.test(message)
30
- ? CODEX_THREAD_NOT_FOUND
31
- : CODEX_JSON_RPC_ERROR;
34
+ if (code !== -32600)
35
+ return CODEX_JSON_RPC_ERROR;
36
+ if (/^no rollout found for thread id\b/i.test(message))
37
+ return CODEX_THREAD_NOT_FOUND;
38
+ // Fixture-backed exact protocol wording: do not broaden this into a heuristic.
39
+ if (/^thread .* already has an active writer$/i.test(message))
40
+ return CODEX_THREAD_WRITER_BUSY;
41
+ return CODEX_JSON_RPC_ERROR;
32
42
  }
33
43
  function boundMessage(message) {
34
44
  return message