@makerstufferupper/flypi 0.1.3 → 0.1.5

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.
@@ -332,7 +332,17 @@ export const PackageRow = Type.Object({
332
332
  scope: Type.Union([Type.Literal("user"), Type.Literal("project")]),
333
333
  installed: Type.Boolean(),
334
334
  path: Type.String(),
335
+ /** The version the installed code's own `package.json` says. A source that names a version is pinned,
336
+ * and this is what tells the reader which version that is (docs/pi-upgrades.md). */
337
+ version: Type.Optional(Type.String()),
335
338
  update: Type.Optional(Type.Boolean()),
339
+ /** The npm package an `npm:` source names, version stripped. Absent for a git URL or a path, and its
340
+ * presence next to a source that is not `npm:<name>` is what reads as pinned. */
341
+ name: Type.Optional(Type.String()),
342
+ /** Published releases, oldest first, from the same daily stamp — what "install this version" offers. */
343
+ versions: Type.Optional(Type.Array(Type.String())),
344
+ /** The newest published release newer than what is installed. */
345
+ available: Type.Optional(Type.String()),
336
346
  });
337
347
 
338
348
  export const PackagesSnapshot = Type.Object({
@@ -367,6 +377,60 @@ export const SavePackagesCommand = Type.Object({
367
377
  requestId: Type.Optional(RequestId),
368
378
  });
369
379
 
380
+ /**
381
+ * Which pi answers, what is on disk to move to, and what the daemon's daily registry answer said (Phase 8.4,
382
+ * docs/pi-upgrades.md). `latest`/`next` are absent until the daemon has asked — the request itself reaches no
383
+ * network, the same rule `packages` follows.
384
+ */
385
+ export const PiVersions = Type.Object({
386
+ /** The version this process loaded. */
387
+ running: Type.String(),
388
+ pinned: Type.Boolean(),
389
+ /** What the tree flypi was installed with holds, when that is not what is running. */
390
+ bundled: Type.Optional(Type.String()),
391
+ /** The pi versions with a tree on disk — what "switch back" can offer. */
392
+ trees: Type.Array(Type.String()),
393
+ next: Type.Optional(Type.String()),
394
+ latest: Type.Optional(Type.String()),
395
+ checkedAt: Type.Optional(Type.String()),
396
+ });
397
+
398
+ export type PiVersions = Static<typeof PiVersions>;
399
+
400
+ /** Ask what pi this flypi runs and what it could move to. Reaches no network. */
401
+ export const PiVersionsCommand = Type.Object({
402
+ type: Type.Literal("piVersions"),
403
+ requestId: Type.Optional(RequestId),
404
+ });
405
+
406
+ /**
407
+ * Ask the registry for pi now instead of on the daemon's daily rhythm. The reply is a fresh `piVersions`, so
408
+ * the page needs no new frame; nothing moves by itself either way.
409
+ */
410
+ export const PiCheckCommand = Type.Object({
411
+ type: Type.Literal("piCheck"),
412
+ requestId: Type.Optional(RequestId),
413
+ });
414
+
415
+ /**
416
+ * Move to a published pi version. The daemon flips the pointer and hands the restart to the CLI — the same
417
+ * path `installUpdate` takes — and the ack goes out before the restart closes this socket. The switch is
418
+ * provisional: if the new process does not answer reporting that version, the pointer goes back on its own
419
+ * (docs/risks.md risk 13), so the page comes back and says which version it is on.
420
+ */
421
+ export const UsePiCommand = Type.Object({
422
+ type: Type.Literal("usePi"),
423
+ version: Type.String({ minLength: 1 }),
424
+ requestId: Type.Optional(RequestId),
425
+ });
426
+
427
+ /** The same call with an older version — only offered for the trees still on disk. */
428
+ export const RollbackPiCommand = Type.Object({
429
+ type: Type.Literal("rollbackPi"),
430
+ version: Type.String({ minLength: 1 }),
431
+ requestId: Type.Optional(RequestId),
432
+ });
433
+
370
434
  /** What kind of secret a row claims to be — the same list the vault's schema allows (src/secrets/crypto.ts). */
371
435
  export const SecretKind = Type.Union([
372
436
  Type.Literal("password"),
@@ -567,6 +631,10 @@ export const ClientCommandSchema = Type.Union([
567
631
  McpRedirectCommand,
568
632
  PackagesCommand,
569
633
  SavePackagesCommand,
634
+ PiVersionsCommand,
635
+ PiCheckCommand,
636
+ UsePiCommand,
637
+ RollbackPiCommand,
570
638
  SecretsCommand,
571
639
  SaveSecretsCommand,
572
640
  RevealSecretCommand,
@@ -579,10 +647,20 @@ export type ClientCommand = Static<typeof ClientCommandSchema>;
579
647
  /** A pi session event, passed through verbatim. Only the discriminator is checked here. */
580
648
  export const SessionEventFrameBody = Type.Object({ type: Type.String() }, { additionalProperties: true });
581
649
 
650
+ /** Which pi answers (docs/pi-upgrades.md Phase 8.1): the version loaded, and what chose it. */
651
+ export const PiInfoFrame = Type.Object({
652
+ version: Type.String(),
653
+ pinned: Type.Boolean(),
654
+ /** What the tree flypi was installed with holds, when that is not what is running. */
655
+ bundled: Type.Optional(Type.String()),
656
+ });
657
+
582
658
  export const HelloFrame = Type.Object({
583
659
  type: Type.Literal("hello"),
584
660
  protocolVersion: Type.Integer(),
585
661
  serverVersion: Type.String(),
662
+ /** Optional so an older server still parses; every flypi server from here on sends it. */
663
+ pi: Type.Optional(PiInfoFrame),
586
664
  });
587
665
 
588
666
  export const EventFrame = Type.Object({
@@ -817,6 +895,7 @@ export const ResultFrame = Type.Object({
817
895
  mcpStatus: Type.Optional(McpStatusSnapshot),
818
896
  config: Type.Optional(ConfigSnapshot),
819
897
  packages: Type.Optional(PackagesSnapshot),
898
+ pi: Type.Optional(PiVersions),
820
899
  scheduler: Type.Optional(SchedulerSnapshot),
821
900
  secrets: Type.Optional(SecretsSnapshot),
822
901
  revealed: Type.Optional(RevealedSecret),
@@ -126,11 +126,33 @@ export class SessionRegistry {
126
126
  );
127
127
  }
128
128
 
129
- /** Rebind a conversation to an existing session file. The conversationId — what the client holds — does not change. */
129
+ /**
130
+ * Open an existing session for a client: the conversation already answering it when there is one, and
131
+ * otherwise this conversation moved onto it — or a new one, if this conversation is mid-turn.
132
+ */
130
133
  async switchSession(conversationId: string, sessionPath: string): Promise<Conversation> {
131
134
  const conversation = this.get(conversationId);
132
135
  const target = this.sessionFileIn(sessionPath);
136
+ // Already answering it: opening the file a second time would put a second writer on it, so the
137
+ // conversation the page is already on stays where it is. An idle one is reopened as asked — that is
138
+ // how a client reloads a session's history from disk.
139
+ if (conversation.session.sessionFile === target && !conversation.session.isIdle) return conversation;
140
+ // A session another conversation is already answering **is** that conversation, not a copy of it. Opening
141
+ // the file a second time would put a second writer on it and freeze the transcript at the moment of the
142
+ // switch, so the answer on its way would never reach the page that just asked for that session.
143
+ const live = [...this.conversations.values()].find((other) => other.id !== conversationId && other.session.sessionFile === target);
144
+ if (live) return live;
145
+ // Rebinding disposes the session this conversation is answering in, and a turn still running would be cut
146
+ // short: its answer would reach neither the page nor the session file. So a session with no conversation
147
+ // of its own is opened as one, and this conversation is left alone to finish (it stays in the map, and
148
+ // switching back to it returns it from the lookup above, log and all).
149
+ if (!conversation.session.isIdle) return this.bind(SessionManager.open(target, this.paths.sessionsDir));
133
150
  this.teardown(conversation);
151
+ // The conversation lives on with a new session, so it is not disposed any more. Without this the
152
+ // exactly-once guard below stays set and the *new* session is never unsubscribed or disposed — it
153
+ // keeps streaming into this conversation's log (and its listeners) after the page moved on, keeps
154
+ // the conversation unevictable, and writes back any session file it is deleted out from under.
155
+ conversation.disposed = false;
134
156
  conversation.session = await this.createSession({
135
157
  sessionManager: SessionManager.open(target, this.paths.sessionsDir),
136
158
  conversationId,
@@ -171,7 +193,9 @@ export class SessionRegistry {
171
193
 
172
194
  get(conversationId: string): Conversation {
173
195
  const conversation = this.conversations.get(conversationId);
174
- if (!conversation) throw new Error(`unknown conversation ${conversationId}`);
196
+ // The code is what a page acts on: it holds a conversation id across reconnects, and after a restart
197
+ // every command it sends names a conversation this process never had (ui/lib/app-state.svelte.ts).
198
+ if (!conversation) throw Object.assign(new Error(`unknown conversation ${conversationId}`), { code: "unknownConversation" });
175
199
  return conversation;
176
200
  }
177
201