chesspal 0.3.6 → 0.4.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.
Files changed (136) hide show
  1. package/dist/cli/cli.js +7 -2
  2. package/dist/cli/command-line.js +5 -3
  3. package/dist/cli/prompt-hook.d.ts +17 -0
  4. package/dist/cli/prompt-hook.js +100 -0
  5. package/dist/commands/analysis/analyse.js +8 -14
  6. package/dist/commands/analysis/annotate.d.ts +8 -0
  7. package/dist/commands/analysis/annotate.js +19 -3
  8. package/dist/commands/answers/agents.d.ts +7 -5
  9. package/dist/commands/answers/agents.js +16 -9
  10. package/dist/commands/answers/clock.js +2 -1
  11. package/dist/commands/answers/drawings.js +6 -2
  12. package/dist/commands/answers/index.d.ts +4 -3
  13. package/dist/commands/answers/index.js +4 -3
  14. package/dist/commands/answers/lines.d.ts +5 -3
  15. package/dist/commands/answers/lines.js +14 -7
  16. package/dist/commands/answers/opening-book.d.ts +13 -7
  17. package/dist/commands/answers/opening-book.js +34 -38
  18. package/dist/commands/answers/opening-tags.d.ts +4 -0
  19. package/dist/commands/answers/opening-tags.js +11 -0
  20. package/dist/commands/answers/play-move.d.ts +3 -1
  21. package/dist/commands/answers/play-move.js +9 -6
  22. package/dist/commands/answers/say.js +2 -1
  23. package/dist/commands/answers/summary.d.ts +5 -2
  24. package/dist/commands/answers/summary.js +16 -5
  25. package/dist/commands/game/ai-move.js +4 -4
  26. package/dist/commands/game/coach.js +7 -6
  27. package/dist/commands/game/end.js +2 -2
  28. package/dist/commands/game/export.d.ts +1 -1
  29. package/dist/commands/game/index.d.ts +1 -1
  30. package/dist/commands/game/index.js +1 -1
  31. package/dist/commands/game/new.js +41 -11
  32. package/dist/commands/game/player-move.d.ts +16 -0
  33. package/dist/commands/game/player-move.js +69 -19
  34. package/dist/commands/game/ponder.js +4 -1
  35. package/dist/commands/game/status.d.ts +1 -1
  36. package/dist/commands/game/status.js +6 -6
  37. package/dist/commands/index.d.ts +3 -3
  38. package/dist/commands/index.js +3 -3
  39. package/dist/commands/profiles/past-games.d.ts +1 -1
  40. package/dist/commands/profiles/past-games.js +21 -10
  41. package/dist/commands/registry/context.d.ts +2 -0
  42. package/dist/commands/registry/context.js +5 -0
  43. package/dist/commands/registry/index.d.ts +1 -1
  44. package/dist/commands/registry/index.js +1 -1
  45. package/dist/commands/talk/ai-say.d.ts +1 -1
  46. package/dist/commands/talk/ai-say.js +6 -5
  47. package/dist/core/game.d.ts +13 -0
  48. package/dist/core/game.js +29 -3
  49. package/dist/core/index.d.ts +4 -4
  50. package/dist/core/index.js +3 -3
  51. package/dist/core/openings.d.ts +20 -6
  52. package/dist/core/openings.js +45 -22
  53. package/dist/core/pgn.d.ts +4 -0
  54. package/dist/core/pgn.js +35 -6
  55. package/dist/games/archive.d.ts +25 -5
  56. package/dist/games/archive.js +52 -19
  57. package/dist/games/chat-start.d.ts +2 -2
  58. package/dist/games/chat-start.js +6 -1
  59. package/dist/games/current-game.d.ts +2 -2
  60. package/dist/games/current-game.js +3 -2
  61. package/dist/games/index.d.ts +2 -1
  62. package/dist/games/index.js +2 -1
  63. package/dist/games/position-shown.d.ts +24 -0
  64. package/dist/games/position-shown.js +51 -0
  65. package/dist/home/index.d.ts +1 -1
  66. package/dist/home/index.js +1 -1
  67. package/dist/home/same-path.d.ts +6 -1
  68. package/dist/home/same-path.js +22 -3
  69. package/dist/host-files/host-files.d.ts +5 -0
  70. package/dist/host-files/host-files.js +13 -4
  71. package/dist/host-files/index.d.ts +1 -1
  72. package/dist/host-files/index.js +1 -1
  73. package/dist/page/app/app.js +30 -7
  74. package/dist/page/app/served.js +9 -4
  75. package/dist/page/avatars/torretta.css +4 -4
  76. package/dist/page/base.css +1 -1
  77. package/dist/page/board/board.css +4 -2
  78. package/dist/page/dialogs/dialogs.css +3 -1
  79. package/dist/page/game/column.js +10 -8
  80. package/dist/page/game/game.css +86 -6
  81. package/dist/page/game/plies.js +4 -0
  82. package/dist/page/game/review-progress.js +11 -0
  83. package/dist/page/guide/drawings/drawings.css +0 -1
  84. package/dist/page/guide/en.md +2 -2
  85. package/dist/page/guide/it.md +2 -2
  86. package/dist/page/layout/icon.ico +0 -0
  87. package/dist/page/layout/layout.css +32 -15
  88. package/dist/page/layout/window.js +41 -2
  89. package/dist/page/legend/legend.css +4 -16
  90. package/dist/page/legend/legend.js +28 -15
  91. package/dist/page/profile/settings.js +1 -0
  92. package/dist/page/table/action.js +42 -0
  93. package/dist/page/table/table.css +57 -39
  94. package/dist/page/table/window.js +67 -67
  95. package/dist/page/talk/talk.css +52 -9
  96. package/dist/page/talk/talk.js +65 -20
  97. package/dist/page/talk/voice.js +92 -21
  98. package/dist/page/text.js +24 -8
  99. package/dist/server/bell/bell.d.ts +24 -27
  100. package/dist/server/bell/bell.js +79 -143
  101. package/dist/server/bell/index.d.ts +1 -1
  102. package/dist/server/bell/index.js +2 -2
  103. package/dist/server/mcp/mcp.js +49 -7
  104. package/dist/server/page/analyses.js +15 -4
  105. package/dist/server/page/files.js +3 -1
  106. package/dist/server/page/game.d.ts +5 -0
  107. package/dist/server/page/game.js +78 -13
  108. package/dist/server/page/http.d.ts +1 -1
  109. package/dist/server/page/page.d.ts +14 -9
  110. package/dist/server/page/page.js +111 -26
  111. package/dist/server/page/table.d.ts +38 -4
  112. package/dist/server/page/table.js +61 -23
  113. package/dist/server/page/windows.d.ts +22 -0
  114. package/dist/server/page/windows.js +45 -0
  115. package/dist/server/ports/index.d.ts +1 -0
  116. package/dist/server/ports/index.js +2 -0
  117. package/dist/server/ports/ports.d.ts +78 -0
  118. package/dist/server/ports/ports.js +222 -0
  119. package/dist/server/table/browser.d.ts +22 -6
  120. package/dist/server/table/browser.js +98 -20
  121. package/dist/server/table/hosts/hooks.d.ts +6 -0
  122. package/dist/server/table/hosts/hooks.js +38 -0
  123. package/dist/server/table/hosts/hosts.d.ts +13 -5
  124. package/dist/server/table/hosts/hosts.js +16 -10
  125. package/dist/server/table/hosts/index.d.ts +1 -0
  126. package/dist/server/table/hosts/index.js +1 -0
  127. package/dist/server/table/hosts/play.d.ts +6 -4
  128. package/dist/server/table/hosts/play.js +58 -16
  129. package/dist/server/table/index.d.ts +1 -1
  130. package/dist/server/table/launch.d.ts +5 -4
  131. package/dist/server/table/launch.js +18 -5
  132. package/dist/server/table/link.js +1 -0
  133. package/dist/server/table/table.d.ts +7 -0
  134. package/dist/server/table/table.js +39 -11
  135. package/instructions/opponent.md +1 -1
  136. package/package.json +1 -1
package/dist/page/text.js CHANGED
@@ -30,6 +30,7 @@ const english = {
30
30
  sounds: "Sounds",
31
31
  voice: "Read the AI's words aloud",
32
32
  noVoice: "This browser has no voice for this language: add one in your system's settings",
33
+ skipReading: "Stop reading this aloud",
33
34
  saidMove: (move) => moveInWords(englishMoveWords, move),
34
35
  newGame: "New game",
35
36
  offerDraw: "Offer a draw",
@@ -176,21 +177,25 @@ const english = {
176
177
  choose: "Choose the chat to play with: it opens in a window of its own, to keep open.",
177
178
  reviewTitle: "Who reviews the game?",
178
179
  reviewChoose: "Choose the chat that reviews the game: it opens in a window of its own, to keep open.",
179
- restartHint: "Choose the model and effort, then press Claude Code or Codex: the chat starts again, clean, in the same window, once it has ended its answer. The game stays as it is.",
180
+ restartHint: "The chat starts again, clean, in the same window, once it has ended its answer, with the chat, model and effort chosen here. The game stays as it is.",
180
181
  byHand: (name) => `You opened this chat yourself: change ${name}'s model with /model in its window, or close it and open one from here, with the choices below.`,
181
182
  noChat: "No chat at the table: choose one",
182
183
  change: "Change the chat or its model",
183
184
  install: (name) => `To play with ${name}, install Claude Code or Codex, then open this page again.`,
185
+ chat: "Chat",
184
186
  model: "Model",
185
187
  effort: "Effort",
186
- asHost: (host) => `as set in ${host}`,
187
- opening: "Opening the chat… In the terminal, if it asks whether you trust the folder, choose Yes with the arrow keys and press Enter.",
188
+ atTable: "At the table",
189
+ act: { open: (host) => `Start ${host}`, review: (host) => `Review with ${host}`, restart: (host) => `Restart ${host}`, switch: (host) => `Switch to ${host}` },
190
+ opening: "Opening the chat… In the terminal, if it asks whether you trust the folder, say yes (in Claude Code, go down to Yes with the arrow keys) and press Enter; with Codex, on the hooks, choose Trust all and continue.",
188
191
  again: (name) => `${name} starts again with a clean chat for the new game…`,
189
192
  restart: "The chat starts again, clean…",
190
193
  waiting: (name) => `${name} starts again once it has ended its answer…`,
191
- restartChanged: "Starts again, clean, with the new choices",
194
+ newGameWaiting: (name) => `The new game starts once ${name} has ended its answer…`,
192
195
  restartNow: "Start again now: it cuts the answer it is giving",
193
196
  cancel: "Do not start again",
197
+ newGameNow: "Start the new game now: it cuts the answer it is giving",
198
+ newGameCancel: "Do not start the new game",
194
199
  failed: "The terminal did not open: type the command below in a terminal.",
195
200
  notRestarted: "The chat did not start again: close its window, then open another from here.",
196
201
  typeIt: "Or type in a terminal:",
@@ -205,6 +210,7 @@ const english = {
205
210
  "ai-to-move": () => "Wait for the AI's move.",
206
211
  illegal: () => "That move is not legal here.",
207
212
  "clear-chat": (most, name) => english.clearChat(name),
213
+ "new-game-profile": () => "The new game did not start: you changed profile while it waited. Start it again from this one.",
208
214
  "words-empty": () => "Write something first.",
209
215
  "words-spent": () => "You have used up your words for this move: write again after the AI's move.",
210
216
  "words-long": (most) => `Write at most ${most} characters.`,
@@ -215,6 +221,8 @@ const english = {
215
221
  "profile-game": () => english.profile.whilePlaying,
216
222
  "game-going": () => "A game is going on: finish it before you bring another to analyse.",
217
223
  "pgn-unreadable": () => "Chesspal cannot read this PGN: check you copied the whole game, its tags and its moves.",
224
+ "game-missing": () => "This game is no longer there: pick another.",
225
+ "game-not-archived": () => "The game just over is not saved yet: try again in a moment.",
218
226
  "update-game": () => english.update.whilePlaying,
219
227
  "update-chat": (most, name) => `${name} is answering: update once it has done.`,
220
228
  },
@@ -460,6 +468,7 @@ const italian = {
460
468
  sounds: "Suoni",
461
469
  voice: "Leggi ad alta voce le parole dell'AI",
462
470
  noVoice: "Questo browser non ha una voce per questa lingua: aggiungila nelle impostazioni del sistema",
471
+ skipReading: "Smetti di leggere questo a voce",
463
472
  saidMove: (move) => moveInWords(italianMoveWords, move),
464
473
  newGame: "Nuova partita",
465
474
  offerDraw: "Proponi patta",
@@ -606,21 +615,25 @@ const italian = {
606
615
  choose: "Scegli la chat con cui giocare: parte in una finestra a parte, da tenere aperta.",
607
616
  reviewTitle: "Chi analizza la partita?",
608
617
  reviewChoose: "Scegli la chat che analizza la partita: parte in una finestra a parte, da tenere aperta.",
609
- restartHint: "Scegli modello e ragionamento, poi premi Claude Code o Codex: la chat riparte pulita nella stessa finestra, appena ha finito di rispondere. La partita resta com'è.",
618
+ restartHint: "La chat riparte pulita nella stessa finestra, appena ha finito di rispondere, con la chat, il modello e il ragionamento scelti qui. La partita resta com'è.",
610
619
  byHand: (name) => `Questa chat l'hai aperta tu: cambia il modello di ${name} con /model nella sua finestra, oppure chiudila e aprine una da qui, con le scelte qui sotto.`,
611
620
  noChat: "Nessuna chat al tavolo: scegline una",
612
621
  change: "Cambia la chat o il modello",
613
622
  install: (name) => `Per giocare con ${name}, installa Claude Code o Codex, poi apri di nuovo questa pagina.`,
623
+ chat: "Chat",
614
624
  model: "Modello",
615
625
  effort: "Ragionamento",
616
- asHost: (host) => `come è impostato in ${host}`,
617
- opening: "Apro la chat… Nel terminale, se ti chiede se ti fidi della cartella, scegli Yes con le frecce e premi Invio.",
626
+ atTable: "Al tavolo",
627
+ act: { open: (host) => `Avvia ${host}`, review: (host) => `Analizza con ${host}`, restart: (host) => `Riavvia ${host}`, switch: (host) => `Passa a ${host}` },
628
+ opening: "Apro la chat… Nel terminale, se ti chiede se ti fidi della cartella, rispondi sì (in Claude Code scendi su Yes con le frecce) e premi Invio; con Codex, sugli «hook», scegli Trust all and continue.",
618
629
  again: (name) => `${name} riparte con una chat pulita per la nuova partita…`,
619
630
  restart: "La chat riparte pulita…",
620
631
  waiting: (name) => `${name} riparte appena ha finito di rispondere…`,
621
- restartChanged: "Riparte pulita con le nuove scelte",
632
+ newGameWaiting: (name) => `La nuova partita comincia appena ${name} ha finito di rispondere…`,
622
633
  restartNow: "Riparti adesso: interrompe la risposta in corso",
623
634
  cancel: "Non ripartire",
635
+ newGameNow: "Comincia adesso la nuova partita: interrompe la risposta in corso",
636
+ newGameCancel: "Non cominciare la nuova partita",
624
637
  failed: "Il terminale non si è aperto: scrivi il comando qui sotto in un terminale.",
625
638
  notRestarted: "La chat non è ripartita: chiudi la sua finestra, poi aprine un'altra da qui.",
626
639
  typeIt: "Oppure scrivi in un terminale:",
@@ -635,6 +648,7 @@ const italian = {
635
648
  "ai-to-move": () => "Aspetta la mossa dell'AI.",
636
649
  illegal: () => "Questa mossa qui non è legale.",
637
650
  "clear-chat": (most, name) => italian.clearChat(name),
651
+ "new-game-profile": () => "La nuova partita non è cominciata: hai cambiato profilo mentre aspettava. Ricominciala da questo.",
638
652
  "words-empty": () => "Scrivi prima qualcosa.",
639
653
  "words-spent": () => "Hai finito le parole per questa mossa: scrivi di nuovo dopo quella dell'AI.",
640
654
  "words-long": (most) => `Scrivi al massimo ${most} caratteri.`,
@@ -645,6 +659,8 @@ const italian = {
645
659
  "profile-game": () => italian.profile.whilePlaying,
646
660
  "game-going": () => "C'è una partita in corso: finiscila prima di portarne un'altra da analizzare.",
647
661
  "pgn-unreadable": () => "Chesspal non riesce a leggere questo PGN: controlla di aver copiato la partita intera, intestazioni e mosse.",
662
+ "game-missing": () => "Questa partita non c'è più: scegline un'altra.",
663
+ "game-not-archived": () => "La partita appena finita non è ancora salvata: riprova fra un momento.",
648
664
  "update-game": () => italian.update.whilePlaying,
649
665
  "update-chat": (most, name) => `${name} sta rispondendo: aggiorna quando ha finito.`,
650
666
  },
@@ -1,4 +1,4 @@
1
- import { type IncomingMessage, type ServerResponse } from "node:http";
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  /** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
3
3
  export declare const bellLine = "The player acted on the board page: call status to see it.";
4
4
  /**
@@ -10,6 +10,7 @@ export declare class Bell {
10
10
  private pending;
11
11
  private busy;
12
12
  private woken;
13
+ private hooked;
13
14
  private readonly news;
14
15
  private readonly changed;
15
16
  /**
@@ -25,38 +26,34 @@ export declare class Bell {
25
26
  get listening(): boolean;
26
27
  /** The chat calls a tool, so it is in a turn: true when nobody was listening before. */
27
28
  chatCalls(): boolean;
28
- /** The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. */
29
- ring(): void;
30
- /** A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. */
31
- listen(request: IncomingMessage, response: ServerResponse): void;
29
+ /**
30
+ * The player's message, or one the host sent for the doorbell or an agent, starts the chat's turn, as the prompt hook says: the hook of the turn
31
+ * before is let go without a ring, and what the player does on the page meanwhile waits for the turn's end, as during a tool. A chat nobody heard
32
+ * stays unheard until its first tool, which starts the AI's clock, held till then: the message moves no clock.
33
+ */
34
+ chatPrompted(): void;
35
+ /**
36
+ * The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. In a host that runs no hook nothing tells the end of
37
+ * a turn: an act the chat waits for finds it out of its turn, and nobody hears the player, unless the chat `mayBeAtWork` on the AI's move meanwhile.
38
+ */
39
+ ring(mayBeAtWork?: boolean): void;
40
+ /**
41
+ * A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. A ring no hook heard is dropped when
42
+ * the chat is `told` all of it otherwise: by the player's message that came with the move the prompt hook played.
43
+ */
44
+ listen(request: IncomingMessage, response: ServerResponse, told?: boolean): void;
32
45
  /** The chat left the table (D-104): its process ended, and nobody is in a turn or waits for the bell; a ring no hook heard is no longer news for anyone. */
33
46
  chatLeft(): void;
34
47
  private tellIf;
35
48
  }
36
49
  /**
37
- * Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, whether a browser has the page open, Chesspal's
38
- * version, and whether the table serves it (D-104), with the session and the folder of the chat attached to it, never its own process's.
50
+ * Notes in the data folder the port a chat's page took off its folder's usual ports, one file per page, for the hook to find it there; then forgets, off
51
+ * the caller's clock, the notes of its folder whose page is gone: nobody listens at the port, or another folder's page does. A page that answers late
52
+ * is busy, not gone, and keeps its note. It throws when the note cannot be written.
39
53
  */
40
- export declare function bellProbe(folder: string, open?: boolean, table?: TableChat): BellProbe;
41
- /** The chat attached to the table, as its probe says it: its session, null for none or for Codex, and its folder; neither while no chat is attached. */
42
- export interface TableChat {
43
- session: string | null;
44
- chat?: string;
45
- }
46
- /** What a page says of itself at `/api/bell?probe`. */
47
- export interface BellProbe {
48
- session: string | null;
49
- folder: string;
50
- open: boolean;
51
- version?: string;
52
- table?: true;
53
- /** The folder of the chat attached to the table. */
54
- chat?: string;
55
- }
56
- /** What the page at a port of this computer says of itself; undefined when none answers there. */
57
- export declare function pageAt(port: number): Promise<BellProbe | undefined>;
58
- /** The ports a folder's page tries first: one in each of a few ranges, by its path, since Windows may reserve a range. The hook looks for it there. */
59
- export declare function pagePorts(folder: string): number[];
54
+ export declare function notePagePort(home: string, folder: string, port: number, wait?: number): Promise<void>;
55
+ /** Forgets the note of a page that closes, unless another folder's page took its port since; a note that cannot go is left for the next page to clear. */
56
+ export declare function forgetPagePort(home: string, folder: string, port: number): void;
60
57
  /** What Codex's Stop hook answers to wake the chat: its turn goes on with the doorbell's line. */
61
58
  export declare const codexBellAnswer: string;
62
59
  /**
@@ -1,13 +1,11 @@
1
1
  // The doorbell for the page (D-47): `chesspal listen`, the Stop hook init writes for Claude Code and Codex, waits at the page of its chat and, when
2
2
  // the player moves or writes there, tells the chat to call status. It never carries the move, the words or the position: status reads them fresh.
3
- import { createHash } from "node:crypto";
4
- import { existsSync, readFileSync } from "node:fs";
5
- import { get } from "node:http";
6
- import { join, resolve } from "node:path";
7
- import { writeAtomically } from "../../games/index.js";
8
- import { dataFolder, samePath, tableFolder } from "../../home/index.js";
3
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { positionShownNote, writeAtomically } from "../../games/index.js";
6
+ import { samePath } from "../../home/index.js";
9
7
  import { analystAgent } from "../../host-files/index.js";
10
- import { version } from "../../version.js";
8
+ import { askPage, chatFolder, findChatPage, notedFolder, notedPagePorts, pageNote, probeOf, probeWait } from "../ports/index.js";
11
9
  /** What the chat hears from the doorbell: one line for whatever the player did, never what, only where to read it. */
12
10
  export const bellLine = "The player acted on the board page: call status to see it.";
13
11
  /**
@@ -17,10 +15,13 @@ export const bellLine = "The player acted on the board page: call status to see
17
15
  export class Bell {
18
16
  listener;
19
17
  pending = false;
20
- // The chat is in a turn: it called a tool, or a hook woke it, and its turn has not ended yet. A chat never started, or just cleared, is not.
18
+ // The chat is in a turn: the player's message started it, it called a tool, or a hook woke it, and its turn has not ended yet. A chat never started,
19
+ // or just cleared, is not.
21
20
  busy = false;
22
21
  // The doorbell woke the chat in this turn: the player acted on the page, and the chat goes to see it.
23
22
  woken = false;
23
+ // A hook has waited for the bell since the chat came: its host runs Chesspal's hooks, and only they tell where a turn ends.
24
+ hooked = false;
24
25
  news;
25
26
  changed;
26
27
  /**
@@ -50,10 +51,31 @@ export class Bell {
50
51
  this.tellIf(asleep);
51
52
  return asleep;
52
53
  }
53
- /** The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. */
54
- ring() {
54
+ /**
55
+ * The player's message, or one the host sent for the doorbell or an agent, starts the chat's turn, as the prompt hook says: the hook of the turn
56
+ * before is let go without a ring, and what the player does on the page meanwhile waits for the turn's end, as during a tool. A chat nobody heard
57
+ * stays unheard until its first tool, which starts the AI's clock, held till then: the message moves no clock.
58
+ */
59
+ chatPrompted() {
60
+ if (this.listener === undefined) {
61
+ return;
62
+ }
63
+ this.listener.end("dismissed");
64
+ this.listener = undefined;
65
+ this.busy = true;
66
+ this.changed();
67
+ }
68
+ /**
69
+ * The player acted on the page: the hook waiting hears it now, or the next one as soon as it comes. In a host that runs no hook nothing tells the end of
70
+ * a turn: an act the chat waits for finds it out of its turn, and nobody hears the player, unless the chat `mayBeAtWork` on the AI's move meanwhile.
71
+ */
72
+ ring(mayBeAtWork = false) {
55
73
  if (this.listener === undefined) {
56
74
  this.pending = true;
75
+ if (!this.hooked && !mayBeAtWork && this.busy) {
76
+ this.busy = false;
77
+ this.changed();
78
+ }
57
79
  return;
58
80
  }
59
81
  this.listener.end("ring");
@@ -62,10 +84,14 @@ export class Bell {
62
84
  this.woken = true;
63
85
  this.changed();
64
86
  }
65
- /** A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. */
66
- listen(request, response) {
87
+ /**
88
+ * A hook waits for the bell, so the chat's turn has ended; the hook before it, of a turn already ended, is let go. A ring no hook heard is dropped when
89
+ * the chat is `told` all of it otherwise: by the player's message that came with the move the prompt hook played.
90
+ */
91
+ listen(request, response, told = false) {
67
92
  response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff" });
68
- const missed = this.pending;
93
+ this.hooked = true;
94
+ const missed = this.pending && !told;
69
95
  this.pending = false;
70
96
  if (missed && this.news()) {
71
97
  response.end("ring");
@@ -89,6 +115,7 @@ export class Bell {
89
115
  }
90
116
  /** The chat left the table (D-104): its process ended, and nobody is in a turn or waits for the bell; a ring no hook heard is no longer news for anyone. */
91
117
  chatLeft() {
118
+ this.hooked = false;
92
119
  this.busy = false;
93
120
  this.woken = false;
94
121
  this.pending = false;
@@ -103,21 +130,27 @@ export class Bell {
103
130
  }
104
131
  }
105
132
  /**
106
- * Whose page answers at a port, for the hook to find its own: the chat's session, the player's folder, whether a browser has the page open, Chesspal's
107
- * version, and whether the table serves it (D-104), with the session and the folder of the chat attached to it, never its own process's.
133
+ * Notes in the data folder the port a chat's page took off its folder's usual ports, one file per page, for the hook to find it there; then forgets, off
134
+ * the caller's clock, the notes of its folder whose page is gone: nobody listens at the port, or another folder's page does. A page that answers late
135
+ * is busy, not gone, and keeps its note. It throws when the note cannot be written.
108
136
  */
109
- export function bellProbe(folder, open = false, table) {
110
- const session = table === undefined ? process.env.CLAUDE_CODE_SESSION_ID ?? null : table.session;
111
- return { session, folder: resolve(folder), open, version, ...(table === undefined ? {} : { table: true, ...(table.chat === undefined ? {} : { chat: table.chat }) }) };
137
+ export function notePagePort(home, folder, port, wait = probeWait) {
138
+ const note = pageNote(home, port);
139
+ mkdirSync(dirname(note), { recursive: true });
140
+ writeAtomically(note, JSON.stringify({ folder: resolve(folder) }));
141
+ return forgetGonePages(home, folder, port, wait);
112
142
  }
113
- /** What the page at a port of this computer says of itself; undefined when none answers there. */
114
- export async function pageAt(port) {
115
- return probeOf(await body(port, "/api/bell?probe", probeWait));
116
- }
117
- /** The ports a folder's page tries first: one in each of a few ranges, by its path, since Windows may reserve a range. The hook looks for it there. */
118
- export function pagePorts(folder) {
119
- const offset = createHash("sha256").update(resolve(folder).toLowerCase()).digest()[0] % 10;
120
- return [5870, 6870, 7870].map((base) => base + offset);
143
+ /** Forgets the note of a page that closes, unless another folder's page took its port since; a note that cannot go is left for the next page to clear. */
144
+ export function forgetPagePort(home, folder, port) {
145
+ const noted = notedFolder(home, port);
146
+ if (noted !== undefined && samePath(noted, folder)) {
147
+ try {
148
+ rmSync(pageNote(home, port), { force: true });
149
+ }
150
+ catch {
151
+ // The next page of the folder clears it.
152
+ }
153
+ }
121
154
  }
122
155
  /** What Codex's Stop hook answers to wake the chat: its turn goes on with the doorbell's line. */
123
156
  export const codexBellAnswer = JSON.stringify({ decision: "block", reason: bellLine });
@@ -159,68 +192,32 @@ export async function waitForBell(input, ports) {
159
192
  */
160
193
  export async function bellHeard(input, ports) {
161
194
  const hook = hookInput(input);
162
- const folder = chatFolder(hook);
163
- const tried = ports ?? pagePorts(folder);
164
- const [probes, table] = await Promise.all([Promise.all(tried.map(async (port) => ({ port, said: await pageAt(port) }))), ports === undefined ? tableOf(folder, hook.session) : undefined]);
165
- const pages = probes.flatMap(({ port, said }) => (said !== undefined && samePath(said.folder, folder) ? [{ port, said }] : []));
166
- // A chat opened in the table's own folder finds the table among the pages of its folder already.
167
- const ours = table === undefined || pages.some(({ port }) => port === table.port) ? pages : [...pages, table];
168
- const port = chatPage(ours, hook.session)?.port;
195
+ const found = await findChatPage(hook, ports);
196
+ const { folder, port } = found;
169
197
  if (port === undefined) {
170
- const others = probes.flatMap((one) => (one.said === undefined ? [] : [`${one.port} is ${one.said.folder}'s`]));
171
- const open = ours.filter(({ said }) => said.open).length;
172
- const pages = ours.length > 1 ? `${ours.length} pages of it, none of this chat's session, ${open} open in a browser` : "no page of it";
173
- return { folder, rang: false, why: `${pages} at ports ${tried.join(", ")}${others.length > 0 ? `; ${others.join(", ")}` : ""}` };
198
+ return { folder, rang: false, why: found.why };
174
199
  }
175
- // The page lets the analyst go as the hook starts waiting, when none runs any more.
176
- const said = await body(port, hook.analystGone === true ? "/api/bell?analyst=gone" : "/api/bell");
200
+ // The page lets the analyst go as the hook starts waiting, when none runs any more; and it rings for nothing older than the move the prompt hook played
201
+ // for this chat, which hears it from the player's message itself.
202
+ const shown = hook.session === undefined ? undefined : positionShownNote(folder);
203
+ const told = shown !== undefined && shown.session === hook.session ? shown.stamp : undefined;
204
+ const query = new URLSearchParams({ ...(hook.analystGone === true ? { analyst: "gone" } : {}), ...(told === undefined ? {} : { shown: told }) });
205
+ const said = (await askPage(port, `/api/bell${query.size === 0 ? "" : `?${query}`}`)).text;
177
206
  return said === "ring" ? { folder, rang: true } : { folder, rang: false, why: said === undefined ? `the page at port ${port} did not answer` : `the page at port ${port} said "${said}"` };
178
207
  }
179
- // How long a port may take to say whose page it is: one of this computer answers at once.
180
- const probeWait = 1000;
181
208
  /**
182
- * The table, when the chat attached to it is the hook's: of its folder, or of its session; none when the data folder cannot be found, or no table has
183
- * this chat. Finding the data folder costs some tens of milliseconds on Windows, off the AI's clock: the hook waits after the turn.
209
+ * Forgets the notes of a folder's pages that are gone, but the one at `kept`: a page closed without forgetting its own, when nobody listens at its port or
210
+ * another folder's page took it. A page that does not answer in time, or not as a page, may only be busy, and its note stays.
184
211
  */
185
- async function tableOf(folder, session) {
186
- let ports;
187
- try {
188
- ports = pagePorts(tableFolder(serverHome(folder) ?? dataFolder()));
189
- }
190
- catch {
191
- return undefined;
192
- }
193
- const probes = await Promise.all(ports.map(async (port) => ({ port, said: await pageAt(port) })));
194
- return probes.find((one) => one.said?.table === true && ((one.said.chat !== undefined && samePath(one.said.chat, folder)) || (session !== undefined && one.said.session === session)));
195
- }
196
- /**
197
- * The data folder the chat's server is given in its folder's settings, as CHESSPAL_HOME in a playtest: the hook runs in the host's environment, not the
198
- * server's (Federico). Claude Code's .mcp.json first, then Codex's config.
199
- */
200
- function serverHome(folder) {
201
- const chosen = [claudeHome(folder), codexHome(folder)].find((one) => one !== undefined && one.trim() !== "");
202
- return chosen === undefined ? undefined : resolve(chosen.trim());
203
- }
204
- function claudeHome(folder) {
205
- try {
206
- const config = JSON.parse(readFileSync(join(folder, ".mcp.json"), "utf8"));
207
- const home = config.mcpServers?.chesspal?.env?.CHESSPAL_HOME;
208
- return typeof home === "string" ? home : undefined;
209
- }
210
- catch {
211
- return undefined;
212
- }
213
- }
214
- // The setting as init keeps it in Codex's table: a basic string, whose escapes are JSON's for a path, or a literal one.
215
- function codexHome(folder) {
216
- try {
217
- const config = readFileSync(join(folder, ".codex", "config.toml"), "utf8");
218
- const [, basic, literal] = /\bCHESSPAL_HOME\s*=\s*(?:("(?:[^"\\]|\\.)*")|'([^']*)')/.exec(config) ?? [];
219
- return basic === undefined ? literal : JSON.parse(basic);
220
- }
221
- catch {
222
- return undefined;
223
- }
212
+ async function forgetGonePages(home, folder, kept, wait) {
213
+ const others = notedPagePorts(home, folder).filter((port) => port !== kept);
214
+ await Promise.all(others.map(async (port) => {
215
+ const { refused, text } = await askPage(port, "/api/bell?probe", wait);
216
+ const said = probeOf(text);
217
+ if (refused || (said !== undefined && !samePath(said.folder, folder))) {
218
+ forgetPagePort(home, folder, port);
219
+ }
220
+ }));
224
221
  }
225
222
  // The log of Codex's hooks that heard no bell, in the chat's folder: its last lines only.
226
223
  const silenceLog = "listen.log";
@@ -236,10 +233,6 @@ export function noteSilence(folder, why, now = new Date()) {
236
233
  // The log only explains; the hook ends the same.
237
234
  }
238
235
  }
239
- /** The chat's folder, where its server started: Claude Code names it, and the hook's cwd follows the cd's of the chat. */
240
- function chatFolder(hook) {
241
- return resolve(process.env.CLAUDE_PROJECT_DIR ?? hook.cwd ?? process.cwd());
242
- }
243
236
  function hookInput(input) {
244
237
  try {
245
238
  const parsed = JSON.parse(input);
@@ -263,60 +256,3 @@ function analystGone(tasks) {
263
256
  return agent === analystAgent && status === "running";
264
257
  });
265
258
  }
266
- /**
267
- * The chat's page among those of its folder: the one of its session; else the only one, or the only one a browser has open, where the player plays. Codex
268
- * gives the page no session, and a chat closed may leave its page behind for a while.
269
- */
270
- function chatPage(pages, session) {
271
- const open = pages.filter(({ said }) => said.open);
272
- return pages.find(({ said }) => session !== undefined && said.session === session) ?? (pages.length === 1 ? pages[0] : open.length === 1 ? open[0] : undefined);
273
- }
274
- function probeOf(said) {
275
- try {
276
- const parsed = said === undefined ? undefined : JSON.parse(said);
277
- return parsed === undefined || typeof parsed.folder !== "string" ? undefined : {
278
- session: typeof parsed.session === "string" ? parsed.session : null,
279
- folder: parsed.folder,
280
- open: parsed.open === true,
281
- ...(typeof parsed.version === "string" ? { version: parsed.version } : {}),
282
- ...(parsed.table === true ? { table: true } : {}),
283
- ...(typeof parsed.chat === "string" ? { chat: parsed.chat } : {}),
284
- };
285
- }
286
- catch {
287
- return undefined;
288
- }
289
- }
290
- /** The body a page of this computer answers with, once it ends; undefined when it does not answer, or not with 200. */
291
- function body(port, path, wait) {
292
- return new Promise((done) => {
293
- // A connection of its own: Node's shared one ends a request idle for five seconds, and the doorbell waits for hours.
294
- const request = get({ host: "127.0.0.1", port, path, agent: false }, (response) => {
295
- let text = "";
296
- response.setEncoding("utf8");
297
- response.on("data", (chunk) => {
298
- text += chunk;
299
- });
300
- response.on("end", () => done(response.statusCode === 200 ? text : undefined));
301
- response.on("error", () => done(undefined));
302
- });
303
- if (wait !== undefined) {
304
- giveUpAfterTurns(request, wait);
305
- }
306
- request.on("error", () => done(undefined));
307
- });
308
- }
309
- /**
310
- * Ends a request still unanswered after `wait`, counted in ten turns of this process's clock: after a stall (a busy computer, one waking up), a timer
311
- * fires before the answer waiting is read, and a page of this process, or one that answered meanwhile, would be taken for one that does not answer.
312
- */
313
- function giveUpAfterTurns(request, wait) {
314
- let turns = 0;
315
- const timer = setInterval(() => {
316
- turns++;
317
- if (turns === 10) {
318
- request.destroy();
319
- }
320
- }, wait / 10);
321
- request.on("close", () => clearInterval(timer));
322
- }
@@ -1 +1 @@
1
- export { Bell, bellProbe, listen, pageAt, pagePorts, type TableChat } from "./bell.ts";
1
+ export { Bell, forgetPagePort, listen, notePagePort } from "./bell.ts";
@@ -1,2 +1,2 @@
1
- // The doorbell that wakes a chat when the player acts on the page (D-47), and the ports where a folder's page answers.
2
- export { Bell, bellProbe, listen, pageAt, pagePorts } from "./bell.js";
1
+ // The doorbell that wakes a chat when the player acts on the page (D-47), and the notes of the ports a folder's page took off its usual ones.
2
+ export { Bell, forgetPagePort, listen, notePagePort } from "./bell.js";
@@ -3,13 +3,12 @@
3
3
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
- import { aiMove, commands, defaultContext, inputProblems, isBoolean, PositionChanged } from "../../commands/index.js";
6
+ import { aiMove, commands, defaultContext, homeForOneCall, inputProblems, isBoolean, PositionChanged, positionIn, samePosition } from "../../commands/index.js";
7
7
  import { aiNameOf } from "../../core/index.js";
8
- import { endOf, loadGame, noteAiHost, noteAiModel, rerunIfChanged } from "../../games/index.js";
8
+ import { endOf, forgetPositionShown, hostProcess, lastChatStart, loadGame, noteAiHost, noteAiModel, positionShownNote, rerunIfChanged } from "../../games/index.js";
9
9
  import { agentTools, hasAgent, startInstructions } from "../../host-files/index.js";
10
10
  import { gameFolder } from "../../profiles/index.js";
11
11
  import { version } from "../../version.js";
12
- import { pagePorts } from "../bell/index.js";
13
12
  import { chatHasRead, startPage } from "../page/index.js";
14
13
  import { attachTable, tableToJoin } from "../table/index.js";
15
14
  import { allowedCaller, callerOf, callingAgent, fromCodexAgent } from "./caller.js";
@@ -108,8 +107,8 @@ async function pageOf(context, opponent, again) {
108
107
  context.toolEnded = table === undefined ? lookForTable(context, again) : page.toolEnded;
109
108
  context.pageOpen = page.open;
110
109
  context.askNewGame = page.askNewGame;
111
- if (table === undefined && !pagePorts(context.folder).includes(Number(new URL(page.url).port))) {
112
- process.stderr.write(`The board page is at ${page.url}, off its usual ports: the doorbell cannot find it, so moves on the page do not wake the chat.\n`);
110
+ if (!page.findable) {
111
+ process.stderr.write(`The board page is at ${page.url}, off its usual ports, and its port could not be noted in the data folder: the doorbell cannot find it, so moves on the page do not wake the chat.\n`);
113
112
  }
114
113
  return page;
115
114
  }
@@ -203,15 +202,17 @@ async function runTool(command, input, context, caller, meta) {
203
202
  const calling = await sharingCaller(command, meta, context);
204
203
  const agent = calling !== undefined && "agent" in calling ? calling.agent : undefined;
205
204
  const chat = command.byAgents === undefined && agent === undefined;
205
+ const home = homeForOneCall(context);
206
206
  let refused = false;
207
207
  try {
208
208
  if (chat) {
209
+ takePositionShown(context, home);
209
210
  await context.calls?.();
210
211
  }
211
212
  const run = calling !== undefined && "run" in calling ? calling.run : undefined;
212
213
  const who = agent !== undefined ? { agent, ...(run === undefined ? {} : { agentRun: run }) } : calling !== undefined ? { chat: true } : {};
213
214
  // A game another process changed under the call is read again, and the call made once more.
214
- const result = await rerunIfChanged(() => command.run(checkedInput(command, input), { ...context, caller, ...who }));
215
+ const result = await rerunIfChanged(() => command.run(checkedInput(command, input), { ...context, home, caller, ...who }));
215
216
  return { content: [{ type: "text", text: command.format(result, context) }] };
216
217
  }
217
218
  catch (error) {
@@ -222,10 +223,51 @@ async function runTool(command, input, context, caller, meta) {
222
223
  // What the tool read or wrote, the chat has seen: the doorbell rings it only for what changes after, and it may move on the game a tool showed it,
223
224
  // unless that tool was refused for a position it had not seen. An agent's call is not the chat's.
224
225
  if (chat) {
225
- chatHasRead(context, command.showsGame === true && !refused);
226
+ chatHasRead(context, command.showsGame === true && !refused, home);
226
227
  }
227
228
  }
228
229
  }
230
+ /**
231
+ * The position the prompt hook showed the chat with the move the player wrote alone (D-71), taken before the chat's tool runs as if a tool had shown it,
232
+ * while the game is still there: the AI moves on it without a status, and the player's words the hook gave count as given, which the page hears. A note
233
+ * on another position goes; one of another chat's stays for it.
234
+ */
235
+ function takePositionShown(context, home) {
236
+ const shown = positionShownNote(context.folder);
237
+ if (shown === undefined) {
238
+ return;
239
+ }
240
+ let current;
241
+ try {
242
+ current = samePosition(shown, positionIn(gameFolder(home())));
243
+ }
244
+ catch {
245
+ // A game that cannot be read is the tool's to report.
246
+ return;
247
+ }
248
+ if (current && !chatsNote(shown, context.folder)) {
249
+ return;
250
+ }
251
+ forgetPositionShown(context.folder);
252
+ if (!current) {
253
+ return;
254
+ }
255
+ shown.given.forEach((key) => context.given.add(key));
256
+ // The position is new to the chat, a move later than any it saw: the page hears of it, and of the words read with it.
257
+ chatHasRead(context, true, home);
258
+ }
259
+ /**
260
+ * Whether a note of the hooks is this server's chat's: by the Claude Code process, which names itself alike to the server and its hooks, when both have its
261
+ * name. Else by the session: the server's is the one Claude Code started it with, and the hooks get a new one after /clear, which the chat's start notes;
262
+ * a chat started or resumed since in the folder is taken for another Claude Code.
263
+ */
264
+ function chatsNote({ process: noted, session }, folder, own = hostProcess(), ownSession = process.env.CLAUDE_CODE_SESSION_ID) {
265
+ if (noted !== undefined && own !== undefined) {
266
+ return noted === own;
267
+ }
268
+ const start = lastChatStart(folder);
269
+ return session === ownSession || (start?.session === session && start.source !== "startup" && start.source !== "resume");
270
+ }
229
271
  /**
230
272
  * Who calls a tool the chat shares with an agent, as the analyst (D-89): the chat, or the agent by its type; looked up only in a folder where one may run
231
273
  * and while no game goes on, so that no call of the chat's waits for it while it plays. None when it cannot be told, or for an agent the tool is not shared with.