@playcraft/cli 0.0.59 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import express from 'express';
2
2
  import crypto from 'node:crypto';
3
3
  import path from 'node:path';
4
4
  import fs from 'node:fs/promises';
5
- import { eq, inArray } from 'drizzle-orm';
5
+ import { eq, inArray } from '@playcraft/common/database/operators';
6
6
  import pc from 'picocolors';
7
7
  import { createSqliteAdapter } from '@playcraft/common/database';
8
8
  import { AssetModel, ProjectModel, SceneModel } from '@playcraft/common/models';
@@ -3,17 +3,12 @@ import { readFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { writePlaycraftCodexConfig } from './config-toml.js';
5
5
  import { createJsonRpcStdioClient } from './jsonrpc-stdio.js';
6
- import { nativeEvent } from './native-adapter.js';
6
+ import { collectedEvent } from './native-adapter.js';
7
7
  function asRecord(value) {
8
8
  return value && typeof value === 'object' && !Array.isArray(value)
9
9
  ? value
10
10
  : {};
11
11
  }
12
- function nestedId(value, key) {
13
- const record = asRecord(value);
14
- const nested = asRecord(record[key]);
15
- return typeof nested.id === 'string' ? nested.id : undefined;
16
- }
17
12
  function waitForChildExit(child) {
18
13
  if (child.exitCode !== null || child.signalCode !== null)
19
14
  return Promise.resolve();
@@ -62,14 +57,10 @@ export function createCodexAppServerAdapter(options) {
62
57
  let startPromise;
63
58
  let live;
64
59
  const exitListeners = new Set();
65
- const unmatchedTurns = new Map();
66
- const turnWaiters = new Map();
67
- function finishTurn(turnId, params, waiter) {
68
- turnWaiters.delete(turnId);
69
- unmatchedTurns.delete(turnId);
70
- const status = String(asRecord(asRecord(params).turn).status ?? 'completed');
71
- const outcome = status === 'interrupted' ? 'interrupted' : status === 'failed' ? 'failed' : 'completed';
72
- waiter.resolve({ outcome, turnId, events: waiter.events });
60
+ const collected = new Set();
61
+ function emitCollected(event) {
62
+ for (const listener of collected)
63
+ listener(event);
73
64
  }
74
65
  async function ensureStarted() {
75
66
  if (readyFlag && rpc)
@@ -103,20 +94,7 @@ export function createCodexAppServerAdapter(options) {
103
94
  });
104
95
  rpc = createJsonRpcStdioClient(child);
105
96
  rpc.onNotification((method, params) => {
106
- const turnId = nestedId(params, 'turn') ?? String(asRecord(params).turnId ?? '');
107
- const event = nativeEvent(method, params);
108
- if (!turnId)
109
- return;
110
- const waiter = turnWaiters.get(turnId);
111
- if (waiter) {
112
- waiter.events.push(event);
113
- if (method === 'turn/completed')
114
- finishTurn(turnId, params, waiter);
115
- return;
116
- }
117
- const queued = unmatchedTurns.get(turnId) ?? [];
118
- queued.push(event);
119
- unmatchedTurns.set(turnId, queued);
97
+ emitCollected(collectedEvent('notification', method, params));
120
98
  });
121
99
  rpc.onServerRequest((id, method, params) => {
122
100
  const payload = asRecord(params);
@@ -127,19 +105,7 @@ export function createCodexAppServerAdapter(options) {
127
105
  threadId: String(payload.threadId ?? ''),
128
106
  turnId: turnId || undefined,
129
107
  });
130
- const event = nativeEvent(method, params);
131
- const waiter = turnId ? turnWaiters.get(turnId) : undefined;
132
- if (waiter) {
133
- waiter.events.push(event);
134
- turnWaiters.delete(turnId);
135
- waiter.resolve({ outcome: 'completed', turnId, approvalId, events: waiter.events });
136
- return;
137
- }
138
- if (turnId) {
139
- const queued = unmatchedTurns.get(turnId) ?? [];
140
- queued.push(event);
141
- unmatchedTurns.set(turnId, queued);
142
- }
108
+ emitCollected(collectedEvent('approval_request', method, { ...payload, itemId: approvalId }));
143
109
  });
144
110
  const pid = child.pid;
145
111
  if (!pid)
@@ -158,10 +124,7 @@ export function createCodexAppServerAdapter(options) {
158
124
  live = undefined;
159
125
  rpc = undefined;
160
126
  child = undefined;
161
- for (const [turnId, waiter] of turnWaiters) {
162
- turnWaiters.delete(turnId);
163
- waiter.resolve({ outcome: 'uncertain', turnId, events: waiter.events });
164
- }
127
+ emitCollected(collectedEvent('process_exit', 'process.exited', { pid: current.pid }));
165
128
  }
166
129
  if (!current.superseded && !closed) {
167
130
  for (const listener of exitListeners)
@@ -189,6 +152,13 @@ export function createCodexAppServerAdapter(options) {
189
152
  onUnexpectedExit(listener) {
190
153
  exitListeners.add(listener);
191
154
  },
155
+ onCollected(listener) {
156
+ collected.add(listener);
157
+ return () => collected.delete(listener);
158
+ },
159
+ turnStartSends() {
160
+ return rpc?.turnStartSends() ?? 0;
161
+ },
192
162
  async replaceProcess() {
193
163
  const previous = live;
194
164
  if (previous)
@@ -197,8 +167,6 @@ export function createCodexAppServerAdapter(options) {
197
167
  startPromise = undefined;
198
168
  threads.clear();
199
169
  approvals.clear();
200
- turnWaiters.clear();
201
- unmatchedTurns.clear();
202
170
  if (previous && previous.child.exitCode === null && previous.child.signalCode === null) {
203
171
  previous.child.kill('SIGTERM');
204
172
  }
@@ -265,104 +233,61 @@ export function createCodexAppServerAdapter(options) {
265
233
  await ensureStarted();
266
234
  if (!rpc)
267
235
  throw new Error('codex_not_started');
268
- if (input.simulateLostReceipt) {
269
- return { outcome: 'uncertain', events: [] };
270
- }
271
236
  if (threads.get(input.threadId)?.sessionId !== input.sessionId) {
272
- return {
273
- outcome: 'failed',
274
- events: [nativeEvent('turn.failed', { reason: 'unknown_thread' })],
275
- };
237
+ return { outcome: 'failed', error: 'unknown_thread' };
276
238
  }
277
- const started = await rpc.request('turn/start', {
278
- threadId: input.threadId,
279
- input: [{ type: 'text', text: input.text }],
280
- });
281
- const turnId = started.turn?.id;
282
- if (!turnId)
283
- return { outcome: 'uncertain', events: [] };
284
- const thread = threads.get(input.threadId);
285
- if (thread)
286
- thread.lastTurnId = turnId;
287
- const queued = unmatchedTurns.get(turnId) ?? [];
288
- unmatchedTurns.delete(turnId);
289
- const events = [nativeEvent('turn/started', started), ...queued];
290
- const alreadyDone = queued.find((event) => event.type === 'turn/completed');
291
- if (alreadyDone) {
292
- const status = String(asRecord(asRecord(alreadyDone.payload).turn).status ?? 'completed');
293
- const outcome = status === 'interrupted' ? 'interrupted' : status === 'failed' ? 'failed' : 'completed';
294
- return { outcome, turnId, events };
239
+ const requestId = rpc.reserveRequest();
240
+ await input.persistReservation({ nativeRequestId: String(requestId) });
241
+ if (input.simulateLostReceipt)
242
+ return { outcome: 'uncertain', threadId: input.threadId };
243
+ try {
244
+ const started = await rpc.sendReserved(requestId, 'turn/start', { threadId: input.threadId, input: [{ type: 'text', text: input.text }] }, turnTimeoutMs);
245
+ const turnId = started.turn?.id;
246
+ if (!turnId)
247
+ return { outcome: 'uncertain', threadId: input.threadId };
248
+ const thread = threads.get(input.threadId);
249
+ if (thread)
250
+ thread.lastTurnId = turnId;
251
+ return { outcome: 'accepted', threadId: input.threadId, turnId };
295
252
  }
296
- const approvalEvent = queued.find((event) => event.type.includes('requestApproval') || event.type.includes('requestUserInput'));
297
- if (approvalEvent) {
298
- const approvalId = String(asRecord(approvalEvent.payload).itemId ?? '');
299
- return { outcome: 'completed', turnId, approvalId, events };
253
+ catch {
254
+ return { outcome: 'uncertain', threadId: input.threadId };
300
255
  }
301
- return await new Promise((resolve) => {
302
- let timer;
303
- const waiter = {
304
- events,
305
- resolve: (result) => {
306
- clearTimeout(timer);
307
- resolve(result);
308
- },
309
- };
310
- timer = setTimeout(() => {
311
- turnWaiters.delete(turnId);
312
- resolve({ outcome: 'uncertain', turnId, events: waiter.events });
313
- }, turnTimeoutMs);
314
- turnWaiters.set(turnId, waiter);
315
- });
316
256
  },
317
257
  async interrupt(input) {
318
258
  await ensureStarted();
319
259
  if (!rpc)
320
- return { outcome: 'uncertain', events: [] };
260
+ return { outcome: 'uncertain' };
321
261
  if (threads.get(input.threadId)?.sessionId !== input.sessionId)
322
- return { outcome: 'uncertain', events: [] };
262
+ return { outcome: 'uncertain' };
323
263
  const turnId = threads.get(input.threadId)?.lastTurnId;
324
264
  if (!turnId)
325
- return { outcome: 'uncertain', events: [] };
265
+ return { outcome: 'uncertain' };
326
266
  try {
327
267
  await rpc.request('turn/interrupt', { threadId: input.threadId, turnId });
328
268
  }
329
269
  catch {
330
- return { outcome: 'uncertain', events: [nativeEvent('turn.interrupted', { threadId: input.threadId })] };
270
+ return { outcome: 'uncertain', turnId };
331
271
  }
332
- return {
333
- outcome: 'interrupted',
334
- turnId,
335
- events: [nativeEvent('turn.interrupted', { threadId: input.threadId, turnId })],
336
- };
272
+ return { outcome: 'accepted', turnId };
337
273
  },
338
274
  async respondApproval(input) {
339
- if (!rpc) {
340
- return {
341
- outcome: 'failed',
342
- events: [nativeEvent('approval.unknown', { approvalId: input.approvalId })],
343
- };
344
- }
275
+ if (!rpc)
276
+ return { outcome: 'failed', approvalId: input.approvalId };
345
277
  const threadId = [...threads].find(([, thread]) => thread.sessionId === input.sessionId)?.[0];
346
278
  const key = JSON.stringify([threadId, input.approvalId]);
347
279
  const pending = approvals.get(key);
348
- if (!pending) {
349
- return {
350
- outcome: 'failed',
351
- events: [nativeEvent('approval.unknown', { approvalId: input.approvalId })],
352
- };
353
- }
280
+ if (!pending)
281
+ return { outcome: 'failed', approvalId: input.approvalId };
354
282
  approvals.delete(key);
355
283
  rpc.respond(pending.rpcId, { decision: input.accepted ? 'accept' : 'decline' });
356
- return {
357
- outcome: 'completed',
284
+ emitCollected(collectedEvent('notification', 'approval.resolved', {
358
285
  approvalId: input.approvalId,
359
- events: [
360
- nativeEvent('approval.resolved', {
361
- approvalId: input.approvalId,
362
- accepted: input.accepted,
363
- }),
364
- ],
365
- };
286
+ accepted: input.accepted,
287
+ threadId,
288
+ turnId: pending.turnId,
289
+ }));
290
+ return { outcome: 'accepted', approvalId: input.approvalId, turnId: pending.turnId };
366
291
  },
367
292
  async close() {
368
293
  if (closed)
@@ -7,6 +7,7 @@ export function createJsonRpcStdioClient(child) {
7
7
  const emitter = new EventEmitter();
8
8
  const pending = new Map();
9
9
  let nextId = 1;
10
+ let turnStarts = 0;
10
11
  let buffer = '';
11
12
  let closed = false;
12
13
  const onData = (chunk) => {
@@ -60,8 +61,16 @@ export function createJsonRpcStdioClient(child) {
60
61
  notify(method, params) {
61
62
  send({ method, params: params ?? {} });
62
63
  },
63
- request(method, params, timeoutMs = 20_000) {
64
- const id = nextId++;
64
+ reserveRequest() {
65
+ const id = nextId;
66
+ nextId += 1;
67
+ return id;
68
+ },
69
+ sendReserved(id, method, params, timeoutMs = 20_000) {
70
+ if (pending.has(id))
71
+ throw new Error('jsonrpc_request_in_use');
72
+ if (method === 'turn/start')
73
+ turnStarts += 1;
65
74
  return new Promise((resolve, reject) => {
66
75
  const timer = setTimeout(() => {
67
76
  pending.delete(id);
@@ -78,6 +87,14 @@ export function createJsonRpcStdioClient(child) {
78
87
  }
79
88
  });
80
89
  },
90
+ request(method, params, timeoutMs = 20_000) {
91
+ const id = nextId;
92
+ nextId += 1;
93
+ return this.sendReserved(id, method, params, timeoutMs);
94
+ },
95
+ turnStartSends() {
96
+ return turnStarts;
97
+ },
81
98
  onNotification(listener) {
82
99
  emitter.on('notification', listener);
83
100
  return () => emitter.off('notification', listener);
@@ -1,59 +1,110 @@
1
- import { nativeEvent } from './native-adapter.js';
1
+ import { collectedEvent } from './native-adapter.js';
2
2
  /** In-process Codex stand-in. Not the App Server; used to prove the operation/event loop. */
3
3
  export function createLoopbackNativeAdapter() {
4
4
  const threads = new Map();
5
5
  const approvals = new Set();
6
+ const collected = new Set();
7
+ const sent = new Set();
8
+ let turnStarts = 0;
9
+ function emit(event) {
10
+ for (const listener of collected)
11
+ listener(event);
12
+ }
6
13
  return {
7
14
  name: 'loopback',
8
15
  ready: () => true,
16
+ onCollected(listener) {
17
+ collected.add(listener);
18
+ return () => collected.delete(listener);
19
+ },
20
+ turnStartSends() {
21
+ return turnStarts;
22
+ },
9
23
  async startThread(input) {
10
24
  const threadId = `thread_${input.sessionId}`;
11
- threads.set(threadId, input.sessionId);
25
+ threads.set(threadId, { sessionId: input.sessionId });
12
26
  return { threadId };
13
27
  },
14
28
  async startTurn(input) {
15
- if (threads.get(input.threadId) !== input.sessionId) {
16
- return {
17
- outcome: 'failed',
18
- events: [nativeEvent('turn.failed', { reason: 'unknown_thread' })],
19
- };
20
- }
21
- if (input.simulateLostReceipt) {
22
- return { outcome: 'uncertain', events: [] };
29
+ const thread = threads.get(input.threadId);
30
+ if (!thread || thread.sessionId !== input.sessionId)
31
+ return { outcome: 'failed', error: 'unknown_thread' };
32
+ const nativeRequestId = `loopback:${input.sessionId}:${input.clientRequestId}`;
33
+ const sentKey = `${input.sessionId}:${input.clientRequestId}`;
34
+ await input.persistReservation({ nativeRequestId });
35
+ if (input.simulateLostReceipt || sent.has(sentKey)) {
36
+ sent.add(sentKey);
37
+ return { outcome: 'uncertain', threadId: input.threadId };
23
38
  }
24
- const turnId = `turn_${input.clientRequestId}`;
25
- const events = [
26
- nativeEvent('item/agentMessage/delta', { text: `loopback:${input.text}` }),
27
- nativeEvent('turn.completed', { turnId, threadId: input.threadId }),
28
- ];
29
- if (input.text.includes('[approval]')) {
30
- const approvalId = `approval_${input.clientRequestId}`;
31
- approvals.add(JSON.stringify([input.sessionId, approvalId]));
32
- events.splice(1, 0, nativeEvent('item/tool/requestUserInput', { approvalId }));
33
- return { outcome: 'completed', turnId, approvalId, events };
34
- }
35
- return { outcome: 'completed', turnId, events };
39
+ sent.add(sentKey);
40
+ turnStarts += 1;
41
+ const turnId = `turn_${input.sessionId}_${input.clientRequestId}`;
42
+ thread.turnId = turnId;
43
+ queueMicrotask(() => {
44
+ emit(collectedEvent('notification', 'item/agentMessage/delta', {
45
+ threadId: input.threadId,
46
+ turnId,
47
+ text: `loopback:${input.text}`,
48
+ delta: `loopback:${input.text}`,
49
+ }));
50
+ if (input.text.includes('[approval]')) {
51
+ const approvalId = `approval_${input.clientRequestId}`;
52
+ approvals.add(JSON.stringify([input.sessionId, approvalId]));
53
+ emit(collectedEvent('approval_request', 'item/tool/requestUserInput', {
54
+ threadId: input.threadId,
55
+ turnId,
56
+ approvalId,
57
+ itemId: approvalId,
58
+ }));
59
+ return;
60
+ }
61
+ emit(collectedEvent('notification', 'turn/completed', {
62
+ threadId: input.threadId,
63
+ turnId,
64
+ turn: { id: turnId, status: 'completed' },
65
+ }));
66
+ });
67
+ return { outcome: 'accepted', threadId: input.threadId, turnId };
36
68
  },
37
69
  async interrupt(input) {
38
- if (threads.get(input.threadId) !== input.sessionId) {
39
- return { outcome: 'uncertain', events: [] };
40
- }
41
- return {
42
- outcome: 'interrupted',
43
- events: [nativeEvent('turn.interrupted', { threadId: input.threadId })],
44
- };
70
+ const thread = threads.get(input.threadId);
71
+ if (!thread || thread.sessionId !== input.sessionId)
72
+ return { outcome: 'uncertain' };
73
+ const turnId = thread.turnId;
74
+ queueMicrotask(() => {
75
+ emit(collectedEvent('notification', 'turn/completed', {
76
+ threadId: input.threadId,
77
+ turnId,
78
+ turn: { id: turnId, status: 'interrupted' },
79
+ }));
80
+ });
81
+ return { outcome: 'accepted', turnId };
45
82
  },
46
83
  async respondApproval(input) {
47
- if (!approvals.has(JSON.stringify([input.sessionId, input.approvalId]))) {
48
- return { outcome: 'failed', events: [nativeEvent('approval.unknown', { approvalId: input.approvalId })] };
49
- }
50
- approvals.delete(JSON.stringify([input.sessionId, input.approvalId]));
51
- const result = {
52
- outcome: 'completed',
53
- approvalId: input.approvalId,
54
- events: [nativeEvent('approval.resolved', { approvalId: input.approvalId, accepted: input.accepted })],
55
- };
56
- return result;
84
+ const key = JSON.stringify([input.sessionId, input.approvalId]);
85
+ if (!approvals.has(key))
86
+ return { outcome: 'failed', approvalId: input.approvalId };
87
+ approvals.delete(key);
88
+ const thread = [...threads].find(([, value]) => value.sessionId === input.sessionId);
89
+ queueMicrotask(() => {
90
+ emit(collectedEvent('notification', 'approval.resolved', {
91
+ approvalId: input.approvalId,
92
+ accepted: input.accepted,
93
+ threadId: thread?.[0],
94
+ turnId: thread?.[1].turnId,
95
+ }));
96
+ emit(collectedEvent('notification', 'item/agentMessage/delta', {
97
+ threadId: thread?.[0],
98
+ turnId: thread?.[1].turnId,
99
+ delta: 'after-approval',
100
+ }));
101
+ emit(collectedEvent('notification', 'turn/completed', {
102
+ threadId: thread?.[0],
103
+ turnId: thread?.[1].turnId,
104
+ turn: { id: thread?.[1].turnId, status: 'completed' },
105
+ }));
106
+ });
107
+ return { outcome: 'accepted', approvalId: input.approvalId, turnId: thread?.[1].turnId };
57
108
  },
58
109
  };
59
110
  }
@@ -1,3 +1,6 @@
1
1
  export function nativeEvent(type, payload) {
2
2
  return { type, payload, occurredAt: new Date().toISOString() };
3
3
  }
4
+ export function collectedEvent(kind, method, params) {
5
+ return { kind, method, params, occurredAt: new Date().toISOString() };
6
+ }