@zetaloop/chappie 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/tools.md CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  Call `init` at the start of local work. Without `sessionId`, it reuses the conversation's saved default or selects an online Pi session with no saved ChatGPT binding. Pass a Pi session ID to resume a specific task, including from another ChatGPT conversation or branch.
21
21
 
22
- `sessions` lists connected sessions with their ID, device, working directory, name, execution status, and binding count. A conversation can address another session for one operation by supplying that tool's optional `sessionId`; only `init({ sessionId })` changes the saved default.
22
+ `sessions` lists connected sessions with their ID, device, working directory, name, execution status, and binding count. The first session tool call establishes the default using its `sessionId` or an online session with no saved bindings. Once a default exists, another tool's `sessionId` selects only that operation's target; `init({ sessionId })` changes the default.
23
23
 
24
24
  Several ChatGPT conversations can use the same Pi session. One conversation can also operate on several Pi sessions explicitly. Requests already assigned to a session continue there even if the conversation later changes its default.
25
25
 
@@ -82,7 +82,7 @@ Call `ask_assert` with the returned ID to confirm that the widget loaded:
82
82
  { "questionId": "<question-id>" }
83
83
  ```
84
84
 
85
- Answers, revisions, and skips arrive later as `webAnswer` in normal Chappie results. `options` can be omitted for a text answer, and `allowMultiple: true` allows several choices. `sessionId` associates the question with a Pi session without changing the conversation's default.
85
+ Answers, revisions, and skips arrive later as `webAnswer` in normal Chappie results. `options` can be omitted for a text answer, and `allowMultiple: true` allows several choices. `sessionId` associates the question with a Pi session using the session selection rules above.
86
86
 
87
87
  Questions remain available after the assistant response and across broker restarts. Pi's own interactive tools remain ordinary Pi tools and can be invoked through `call`.
88
88
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zetaloop/chappie",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Connect ChatGPT to Pi",
5
5
  "devDependencies": {
6
6
  "@earendil-works/pi-ai": "^0.85.1",
package/src/broker.ts CHANGED
@@ -127,12 +127,7 @@ export class Broker {
127
127
  signal: AbortSignal,
128
128
  ): Promise<AbortSignal> {
129
129
  if (!this.#latestWorkflow || !chatId) return signal;
130
- const id =
131
- typeof requestId === "string"
132
- ? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
133
- .exec(requestId)?.[0]
134
- .toLowerCase()
135
- : undefined;
130
+ const id = workflowId(requestId);
136
131
  if (!id) return signal;
137
132
  signal.throwIfAborted();
138
133
  const latest = this.#state.workflow(chatId);
@@ -177,17 +172,16 @@ export class Broker {
177
172
  async initialize(
178
173
  chatId: string,
179
174
  sessionId: string | undefined,
175
+ requestId: unknown,
180
176
  signal: AbortSignal,
181
177
  ): Promise<InitializedSession> {
182
- const previous = this.#state.binding(chatId);
183
178
  const { sessionId: target, selection } = await this.#selectSession(
184
179
  chatId,
185
180
  sessionId,
181
+ requestId,
186
182
  signal,
187
183
  true,
188
184
  );
189
- if (previous === target)
190
- await this.#notify(target, `ChatGPT ${chatId.slice(-4)} joined`);
191
185
  const { inspection, inputs, globalAgents } = await this.#inspect(
192
186
  target,
193
187
  signal,
@@ -209,13 +203,14 @@ export class Broker {
209
203
  chatId: string,
210
204
  sessionId: string | undefined,
211
205
  text: string,
206
+ requestId: unknown,
212
207
  signal: AbortSignal,
213
208
  ): Promise<ChatResult> {
214
209
  const { sessionId: target } = await this.#selectSession(
215
210
  chatId,
216
211
  sessionId,
212
+ requestId,
217
213
  signal,
218
- false,
219
214
  );
220
215
  const result = await this.#request(
221
216
  target,
@@ -233,13 +228,14 @@ export class Broker {
233
228
  chatId: string,
234
229
  sessionId: string | undefined,
235
230
  names: string[] | undefined,
231
+ requestId: unknown,
236
232
  signal: AbortSignal,
237
233
  ): Promise<InspectedSession> {
238
234
  const { sessionId: target } = await this.#selectSession(
239
235
  chatId,
240
236
  sessionId,
237
+ requestId,
241
238
  signal,
242
- false,
243
239
  );
244
240
  const { inspection, inputs } = await this.#inspect(target, signal);
245
241
  await this.#ackInputs(target, inputs);
@@ -257,13 +253,14 @@ export class Broker {
257
253
  chatId: string,
258
254
  sessionId: string | undefined,
259
255
  calls: ToolInput[],
256
+ requestId: unknown,
260
257
  signal: AbortSignal,
261
258
  ): Promise<CallResult> {
262
259
  const { sessionId: target } = await this.#selectSession(
263
260
  chatId,
264
261
  sessionId,
262
+ requestId,
265
263
  signal,
266
- false,
267
264
  );
268
265
  const toolCalls: ToolCall[] = calls.map((call) => ({
269
266
  type: "toolCall",
@@ -297,10 +294,14 @@ export class Broker {
297
294
  async inputs(
298
295
  chatId: string,
299
296
  sessionId: string | undefined,
297
+ requestId: unknown,
300
298
  signal: AbortSignal,
301
299
  ): Promise<SessionInput[]> {
302
- const target = sessionId ?? this.#state.binding(chatId);
300
+ const boundId = this.#state.binding(chatId);
301
+ const target = sessionId ?? boundId;
303
302
  if (!target || !this.#sessions.has(target)) return [];
303
+ signal.throwIfAborted();
304
+ if (target === boundId) await this.#join(chatId, target, requestId);
304
305
  const { inputs } = await this.#inspect(target, signal);
305
306
  await this.#ackInputs(target, inputs);
306
307
  return inputs;
@@ -310,13 +311,14 @@ export class Broker {
310
311
  chatId: string,
311
312
  sessionId: string | undefined,
312
313
  input: QuestionInput,
314
+ requestId: unknown,
313
315
  signal: AbortSignal,
314
316
  ): Promise<Question> {
315
317
  const { sessionId: target } = await this.#selectSession(
316
318
  chatId,
317
319
  sessionId,
320
+ requestId,
318
321
  signal,
319
- false,
320
322
  );
321
323
  const session = this.#sessions.get(target);
322
324
  if (!session) throw new Error(`Pi session ${target} is offline`);
@@ -460,50 +462,66 @@ export class Broker {
460
462
  async #selectSession(
461
463
  chatId: string,
462
464
  requestedId: string | undefined,
465
+ requestId: unknown,
463
466
  signal: AbortSignal,
464
- bindRequested: boolean,
467
+ bindRequested = false,
465
468
  ): Promise<{
466
469
  sessionId: string;
467
470
  selection: InitializedSession["selection"];
468
471
  }> {
469
472
  if (requestedId) {
470
473
  await this.#waitForSession(requestedId, signal);
471
- if (bindRequested && this.#state.binding(chatId) !== requestedId) {
472
- await this.#bind(chatId, requestedId);
474
+ signal.throwIfAborted();
475
+ const boundId = this.#state.binding(chatId);
476
+ if (bindRequested || !boundId || boundId === requestedId) {
477
+ await this.#join(chatId, requestedId, requestId, bindRequested);
473
478
  }
474
479
  return { sessionId: requestedId, selection: "explicit" };
475
480
  }
476
481
 
477
- const boundId = this.#state.binding(chatId);
478
- if (boundId) {
479
- await this.#waitForSession(boundId, signal);
480
- return { sessionId: boundId, selection: "existing" };
481
- }
482
-
483
482
  for (;;) {
483
+ signal.throwIfAborted();
484
+ const boundId = this.#state.binding(chatId);
485
+ if (boundId) {
486
+ await this.#waitForSession(boundId, signal);
487
+ signal.throwIfAborted();
488
+ if (bindRequested || this.#state.binding(chatId) === boundId) {
489
+ await this.#join(chatId, boundId, requestId, bindRequested);
490
+ }
491
+ return { sessionId: boundId, selection: "existing" };
492
+ }
484
493
  const occupied = this.#state.bindingCounts();
485
494
  const candidate = [...this.#sessions.keys()].find(
486
495
  (sessionId) => !occupied.has(sessionId),
487
496
  );
488
497
  if (candidate) {
489
- await this.#bind(chatId, candidate);
498
+ await this.#join(chatId, candidate, requestId, bindRequested);
490
499
  return { sessionId: candidate, selection: "automatic" };
491
500
  }
492
501
  await this.#waitForChange(signal);
493
502
  }
494
503
  }
495
504
 
496
- async #bind(chatId: string, sessionId: string): Promise<void> {
505
+ async #join(
506
+ chatId: string,
507
+ sessionId: string,
508
+ requestId: unknown,
509
+ explicit = false,
510
+ ): Promise<void> {
497
511
  const previous = this.#state.binding(chatId);
498
- await this.#state.setBinding(chatId, sessionId);
499
- this.#notifyChange();
500
- if (previous) {
501
- await this.#notify(previous, `ChatGPT ${chatId.slice(-4)} left`);
502
- if (this.#state.chats(previous).length === 0) {
503
- await this.#notify(previous, "Ready for ChatGPT");
504
- }
512
+ const workflow = workflowId(requestId);
513
+ const joined = await this.#state.join(chatId, sessionId, workflow);
514
+ if (previous !== sessionId) {
515
+ this.#notifyChange();
516
+ if (previous)
517
+ await this.#notify(previous, `ChatGPT ${chatId.slice(-4)} left`);
518
+ }
519
+ if (joined || explicit) {
520
+ await this.#notify(
521
+ sessionId,
522
+ `ChatGPT ${chatId.slice(-4)}${workflow ? ` (${workflow.slice(-4)})` : ""} joined`,
523
+ );
505
524
  }
506
- await this.#notify(sessionId, `ChatGPT ${chatId.slice(-4)} joined`);
507
525
  }
508
526
 
509
527
  async #notify(sessionId: string, message: string): Promise<void> {
@@ -630,6 +648,14 @@ export class Broker {
630
648
  }
631
649
  }
632
650
 
651
+ function workflowId(requestId: unknown): string | undefined {
652
+ return typeof requestId === "string"
653
+ ? /^wfr_[0-9a-f]{12}7[0-9a-f]{3}[89ab][0-9a-f]{15}(?=\/|$)/i
654
+ .exec(requestId)?.[0]
655
+ .toLowerCase()
656
+ : undefined;
657
+ }
658
+
633
659
  function abortError(signal: AbortSignal): Error {
634
660
  return signal.reason instanceof Error
635
661
  ? signal.reason
@@ -1,6 +1,6 @@
1
1
  Chappie connects this ChatGPT conversation to Pi sessions on one or more devices. Resume work with init using the task's Pi sessionId, including in a new chat or branch. When only a project or session name is known, find its device/cwd/name in sessions, then call init. If the target is absent or ambiguous, ask the user to resolve it. Pi sessions appear while the chappie/chatgpt provider is selected.
2
2
 
3
- init sets this chat's default Pi session. For a task without a specified target, omitting sessionId reuses that default or selects the first online session with no saved bindings. These sessions may already contain work. Defaults survive broker restarts, and several chats can share one Pi session. Other tools' sessionId affects only that call.
3
+ init sets this chat's default Pi session. A session tool also establishes a default on first use: sessionId selects its target, or omitting it selects the first online session with no saved bindings. These sessions may already contain work. Once a default exists, another tool's sessionId selects only that call's target. Defaults survive broker restarts, and several chats can share one Pi session.
4
4
 
5
5
  The active model is this existing ChatGPT conversation. A Pi tool that starts another chappie/chatgpt agent has no ChatGPT conversation to attach to and will wait indefinitely. Subagents targeting another configured model keep that provider's normal behavior.
6
6
 
package/src/server.ts CHANGED
@@ -96,6 +96,7 @@ export function createServer(broker: Broker): McpServer {
96
96
  const { inputs, ...initialized } = await broker.initialize(
97
97
  chatId,
98
98
  args.sessionId,
99
+ context.mcpReq._meta?.["otunnel/requestId"],
99
100
  context.mcpReq.signal,
100
101
  );
101
102
  return finishResult(broker, context, textResult(initialized, inputs));
@@ -114,7 +115,9 @@ export function createServer(broker: Broker): McpServer {
114
115
  sessionId: z
115
116
  .string()
116
117
  .optional()
117
- .describe("Pi session for this operation only"),
118
+ .describe(
119
+ "Pi session for this operation; becomes the default if none is set",
120
+ ),
118
121
  }),
119
122
  annotations: toolAnnotations,
120
123
  },
@@ -124,6 +127,7 @@ export function createServer(broker: Broker): McpServer {
124
127
  chatId,
125
128
  args.sessionId,
126
129
  args.text,
130
+ context.mcpReq._meta?.["otunnel/requestId"],
127
131
  context.mcpReq.signal,
128
132
  );
129
133
  return finishResult(
@@ -158,6 +162,7 @@ export function createServer(broker: Broker): McpServer {
158
162
  requireChatId(context),
159
163
  sessionId,
160
164
  input,
165
+ context.mcpReq._meta?.["otunnel/requestId"],
161
166
  context.mcpReq.signal,
162
167
  );
163
168
  const result = await finishResult(broker, context, {
@@ -285,7 +290,9 @@ export function createServer(broker: Broker): McpServer {
285
290
  sessionId: z
286
291
  .string()
287
292
  .optional()
288
- .describe("Pi session for this operation only"),
293
+ .describe(
294
+ "Pi session for this operation; becomes the default if none is set",
295
+ ),
289
296
  }),
290
297
  annotations: toolAnnotations,
291
298
  },
@@ -294,6 +301,7 @@ export function createServer(broker: Broker): McpServer {
294
301
  requireChatId(context),
295
302
  args.sessionId,
296
303
  args.names,
304
+ context.mcpReq._meta?.["otunnel/requestId"],
297
305
  context.mcpReq.signal,
298
306
  );
299
307
  return finishResult(
@@ -326,7 +334,9 @@ export function createServer(broker: Broker): McpServer {
326
334
  sessionId: z
327
335
  .string()
328
336
  .optional()
329
- .describe("Pi session for this operation only"),
337
+ .describe(
338
+ "Pi session for this operation; becomes the default if none is set",
339
+ ),
330
340
  }),
331
341
  annotations: toolAnnotations,
332
342
  },
@@ -335,6 +345,7 @@ export function createServer(broker: Broker): McpServer {
335
345
  requireChatId(context),
336
346
  args.sessionId,
337
347
  args.calls,
348
+ context.mcpReq._meta?.["otunnel/requestId"],
338
349
  context.mcpReq.signal,
339
350
  );
340
351
  return finishResult(
@@ -374,6 +385,7 @@ export function createServer(broker: Broker): McpServer {
374
385
  requireChatId(context),
375
386
  sessionId,
376
387
  calls,
388
+ context.mcpReq._meta?.["otunnel/requestId"],
377
389
  context.mcpReq.signal,
378
390
  );
379
391
  return finishResult(
@@ -408,7 +420,12 @@ export function createServer(broker: Broker): McpServer {
408
420
  handle(async (args, context) => {
409
421
  const chatId = requestChatId(context);
410
422
  const inputs = chatId
411
- ? await broker.inputs(chatId, args.sessionId, context.mcpReq.signal)
423
+ ? await broker.inputs(
424
+ chatId,
425
+ args.sessionId,
426
+ context.mcpReq._meta?.["otunnel/requestId"],
427
+ context.mcpReq.signal,
428
+ )
412
429
  : [];
413
430
  const result = textResult(
414
431
  {
package/src/session.ts CHANGED
@@ -58,7 +58,6 @@ export class LocalSession {
58
58
  readonly #syncs = new Map<number, SyncRequest>();
59
59
  readonly #stores = new Map<string, StoreRequest>();
60
60
  readonly #queue: RemoteRequest[] = [];
61
- readonly #seenInputs = new Set<string>();
62
61
  readonly #pendingInputs = new Map<string, SessionInput>();
63
62
  readonly #deliveries = new Map<string, DeliveryRecord>();
64
63
  #context: ExtensionContext | undefined;
@@ -98,6 +97,12 @@ export class LocalSession {
98
97
  this.#context = context;
99
98
  void this.#sync().catch(() => {});
100
99
  });
100
+ this.#pi.on("session_tree", (event, context) => {
101
+ this.#context = context;
102
+ if (context.model?.provider === "chappie") {
103
+ this.#resetInputs(context, event.newLeafId);
104
+ }
105
+ });
101
106
  this.#pi.on("context", (event, context) => ({
102
107
  messages:
103
108
  context.model?.provider === "chappie"
@@ -171,6 +176,7 @@ export class LocalSession {
171
176
  this.#connection?.close();
172
177
  this.#connection = undefined;
173
178
  this.#context = undefined;
179
+ this.#resetInputs();
174
180
  this.#rejectSyncs(new Error("Chappie session ended"));
175
181
  this.#rejectStores(new Error("Chappie session ended"));
176
182
  }
@@ -184,8 +190,10 @@ export class LocalSession {
184
190
  this.close();
185
191
  return;
186
192
  }
193
+ if (this.#sessionId !== context.sessionManager.getSessionId()) {
194
+ this.#resetInputs(context);
195
+ }
187
196
  if (!this.#connection) {
188
- this.#notify("Connecting to Chappie…");
189
197
  this.#connection = new IpcClient(this.#agentDir, this.#connect, {
190
198
  onOpen: async () => {
191
199
  await this.#sync();
@@ -448,16 +456,23 @@ export class LocalSession {
448
456
  void this.#sync().catch(() => {});
449
457
  }
450
458
 
459
+ #resetInputs(
460
+ context?: ExtensionContext,
461
+ cursor = context?.sessionManager.getLeafId() ?? null,
462
+ ): void {
463
+ this.#sessionId = context?.sessionManager.getSessionId();
464
+ this.#inputCursor = cursor;
465
+ this.#pendingInputs.clear();
466
+ }
467
+
451
468
  #collectInputs(): void {
452
469
  const context = this.#context;
453
470
  if (!context) return;
454
471
  const sessionManager = context.sessionManager;
455
472
  const sessionId = sessionManager.getSessionId();
456
473
  if (this.#sessionId !== sessionId) {
457
- this.#sessionId = sessionId;
458
- this.#inputCursor = null;
459
- this.#seenInputs.clear();
460
- this.#pendingInputs.clear();
474
+ this.#resetInputs(context);
475
+ return;
461
476
  }
462
477
  const leafId = sessionManager.getLeafId();
463
478
  if (leafId === this.#inputCursor) return;
@@ -469,18 +484,16 @@ export class LocalSession {
469
484
  ? sessionManager.getEntry(current.parentId)
470
485
  : undefined;
471
486
  }
487
+ if (this.#inputCursor !== null && !current) {
488
+ this.#resetInputs(context);
489
+ return;
490
+ }
472
491
  for (const entry of entries.reverse()) {
473
- if (
474
- entry.type !== "message" ||
475
- entry.message.role !== "user" ||
476
- this.#seenInputs.has(entry.id)
477
- )
478
- continue;
492
+ if (entry.type !== "message" || entry.message.role !== "user") continue;
479
493
  const message = entry.message as UserMessage;
480
494
  if (typeof message.content !== "string") {
481
495
  rememberImages(sessionId, message.content);
482
496
  }
483
- this.#seenInputs.add(entry.id);
484
497
  this.#pendingInputs.set(entry.id, { id: entry.id, sessionId, message });
485
498
  }
486
499
  this.#inputCursor = leafId;
package/src/state.ts CHANGED
@@ -5,6 +5,7 @@ import type { QuestionAnswer, QuestionRecord } from "./questions.ts";
5
5
 
6
6
  interface StateFile {
7
7
  bindings?: Record<string, string>;
8
+ joins?: Record<string, string[]>;
8
9
  workflows?: Record<string, string>;
9
10
  deliveries?: DeliveryRecord[];
10
11
  questions?: QuestionRecord[];
@@ -14,6 +15,7 @@ export class State {
14
15
  readonly #path: string;
15
16
  readonly #temporaryPath: string;
16
17
  readonly #bindings = new Map<string, string>();
18
+ readonly #joins = new Map<string, Set<string>>();
17
19
  readonly #workflows = new Map<string, string>();
18
20
  readonly #deliveries = new Map<string, DeliveryRecord>();
19
21
  readonly #questions = new Map<string, QuestionRecord>();
@@ -36,6 +38,9 @@ export class State {
36
38
  for (const [chatId, sessionId] of Object.entries(state.bindings ?? {})) {
37
39
  if (typeof sessionId === "string") this.#bindings.set(chatId, sessionId);
38
40
  }
41
+ for (const [chatId, ids] of Object.entries(state.joins ?? {})) {
42
+ this.#joins.set(chatId, new Set(ids));
43
+ }
39
44
  for (const [chatId, id] of Object.entries(state.workflows ?? {})) {
40
45
  this.#workflows.set(chatId, id);
41
46
  }
@@ -59,12 +64,6 @@ export class State {
59
64
  return this.#bindings.get(chatId);
60
65
  }
61
66
 
62
- chats(sessionId: string): string[] {
63
- return [...this.#bindings]
64
- .filter(([, target]) => target === sessionId)
65
- .map(([chatId]) => chatId);
66
- }
67
-
68
67
  bindingCounts(): Map<string, number> {
69
68
  const counts = new Map<string, number>();
70
69
  for (const sessionId of this.#bindings.values()) {
@@ -73,9 +72,21 @@ export class State {
73
72
  return counts;
74
73
  }
75
74
 
76
- setBinding(chatId: string, sessionId: string): Promise<void> {
75
+ async join(
76
+ chatId: string,
77
+ sessionId: string,
78
+ workflowId: string | undefined,
79
+ ): Promise<boolean> {
80
+ const changed = this.#bindings.get(chatId) !== sessionId;
81
+ const workflows = changed
82
+ ? new Set<string>()
83
+ : (this.#joins.get(chatId) ?? new Set<string>());
84
+ if (!changed && (!workflowId || workflows.has(workflowId))) return false;
77
85
  this.#bindings.set(chatId, sessionId);
78
- return this.#save();
86
+ if (workflowId) workflows.add(workflowId);
87
+ this.#joins.set(chatId, workflows);
88
+ await this.#save();
89
+ return true;
79
90
  }
80
91
 
81
92
  deliveries(chatId: string): DeliveryRecord[] {
@@ -168,10 +179,13 @@ export class State {
168
179
  #save(): Promise<void> {
169
180
  const saved = this.#writes.then(async () => {
170
181
  const bindings = Object.fromEntries(this.#bindings);
182
+ const joins = Object.fromEntries(
183
+ [...this.#joins].map(([chatId, ids]) => [chatId, [...ids]]),
184
+ );
171
185
  const workflows = Object.fromEntries(this.#workflows);
172
186
  await writeFile(
173
187
  this.#temporaryPath,
174
- `${JSON.stringify({ bindings, workflows, deliveries: [...this.#deliveries.values()], questions: [...this.#questions.values()] }, null, 2)}\n`,
188
+ `${JSON.stringify({ bindings, joins, workflows, deliveries: [...this.#deliveries.values()], questions: [...this.#questions.values()] }, null, 2)}\n`,
175
189
  {
176
190
  mode: 0o600,
177
191
  },
package/src/tools.ts CHANGED
@@ -73,7 +73,8 @@ function withSessionId(
73
73
  ...copy.properties,
74
74
  sessionId: {
75
75
  type: "string",
76
- description: "Pi session for this operation only",
76
+ description:
77
+ "Pi session for this operation; becomes the default if none is set",
77
78
  },
78
79
  },
79
80
  additionalProperties: false,