@possumtech/rummy 0.5.0 → 2.0.1

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 (157) hide show
  1. package/.env.example +42 -5
  2. package/PLUGINS.md +389 -194
  3. package/README.md +25 -8
  4. package/SPEC.md +934 -373
  5. package/bin/demo.js +166 -0
  6. package/bin/rummy.js +9 -3
  7. package/biome/no-fallbacks.grit +50 -0
  8. package/lang/en.json +2 -2
  9. package/migrations/001_initial_schema.sql +88 -37
  10. package/package.json +13 -11
  11. package/scriptify/ask_run.js +77 -0
  12. package/service.js +50 -9
  13. package/src/agent/AgentLoop.js +476 -335
  14. package/src/agent/ContextAssembler.js +4 -4
  15. package/src/agent/Entries.js +676 -0
  16. package/src/agent/ProjectAgent.js +30 -18
  17. package/src/agent/TurnExecutor.js +232 -421
  18. package/src/agent/XmlParser.js +99 -33
  19. package/src/agent/budget.js +56 -0
  20. package/src/agent/errors.js +22 -0
  21. package/src/agent/httpStatus.js +39 -0
  22. package/src/agent/known_checks.sql +8 -4
  23. package/src/agent/known_queries.sql +9 -13
  24. package/src/agent/known_store.sql +280 -125
  25. package/src/agent/materializeContext.js +104 -0
  26. package/src/agent/runs.sql +29 -7
  27. package/src/agent/schemes.sql +14 -3
  28. package/src/agent/tokens.js +6 -0
  29. package/src/agent/turns.sql +9 -9
  30. package/src/hooks/HookRegistry.js +6 -5
  31. package/src/hooks/Hooks.js +44 -3
  32. package/src/hooks/PluginContext.js +29 -21
  33. package/src/{server → hooks}/RpcRegistry.js +2 -1
  34. package/src/hooks/RummyContext.js +139 -35
  35. package/src/hooks/ToolRegistry.js +21 -16
  36. package/src/llm/LlmProvider.js +66 -89
  37. package/src/llm/errors.js +21 -0
  38. package/src/llm/retry.js +63 -0
  39. package/src/plugins/ask_user/README.md +1 -1
  40. package/src/plugins/ask_user/ask_user.js +37 -12
  41. package/src/plugins/ask_user/ask_userDoc.js +2 -25
  42. package/src/plugins/ask_user/ask_userDoc.md +10 -0
  43. package/src/plugins/budget/README.md +27 -25
  44. package/src/plugins/budget/budget.js +306 -88
  45. package/src/plugins/cp/README.md +2 -2
  46. package/src/plugins/cp/cp.js +29 -11
  47. package/src/plugins/cp/cpDoc.js +2 -15
  48. package/src/plugins/cp/cpDoc.md +7 -0
  49. package/src/plugins/engine/README.md +2 -2
  50. package/src/plugins/engine/engine.sql +4 -4
  51. package/src/plugins/engine/turn_context.sql +10 -10
  52. package/src/plugins/env/README.md +20 -5
  53. package/src/plugins/env/env.js +45 -6
  54. package/src/plugins/env/envDoc.js +2 -23
  55. package/src/plugins/env/envDoc.md +13 -0
  56. package/src/plugins/error/README.md +16 -0
  57. package/src/plugins/error/error.js +151 -0
  58. package/src/plugins/file/README.md +6 -6
  59. package/src/plugins/file/file.js +15 -2
  60. package/src/plugins/get/README.md +1 -1
  61. package/src/plugins/get/get.js +103 -48
  62. package/src/plugins/get/getDoc.js +2 -32
  63. package/src/plugins/get/getDoc.md +36 -0
  64. package/src/plugins/hedberg/README.md +1 -2
  65. package/src/plugins/hedberg/hedberg.js +8 -4
  66. package/src/plugins/hedberg/matcher.js +16 -17
  67. package/src/plugins/hedberg/normalize.js +0 -48
  68. package/src/plugins/helpers.js +42 -2
  69. package/src/plugins/index.js +146 -123
  70. package/src/plugins/instructions/README.md +35 -9
  71. package/src/plugins/instructions/instructions.js +244 -9
  72. package/src/plugins/instructions/instructions.md +33 -0
  73. package/src/plugins/instructions/instructions_104.md +7 -0
  74. package/src/plugins/instructions/instructions_105.md +38 -0
  75. package/src/plugins/instructions/instructions_106.md +21 -0
  76. package/src/plugins/instructions/instructions_107.md +10 -0
  77. package/src/plugins/instructions/instructions_108.md +0 -0
  78. package/src/plugins/instructions/protocol.js +12 -0
  79. package/src/plugins/known/README.md +2 -2
  80. package/src/plugins/known/known.js +68 -36
  81. package/src/plugins/known/knownDoc.js +2 -17
  82. package/src/plugins/known/knownDoc.md +8 -0
  83. package/src/plugins/log/README.md +48 -0
  84. package/src/plugins/log/log.js +129 -0
  85. package/src/plugins/mv/README.md +2 -2
  86. package/src/plugins/mv/mv.js +55 -22
  87. package/src/plugins/mv/mvDoc.js +2 -18
  88. package/src/plugins/mv/mvDoc.md +10 -0
  89. package/src/plugins/ollama/README.md +15 -0
  90. package/src/{llm/OllamaClient.js → plugins/ollama/ollama.js} +40 -18
  91. package/src/plugins/openai/README.md +17 -0
  92. package/src/plugins/openai/openai.js +120 -0
  93. package/src/plugins/openrouter/README.md +27 -0
  94. package/src/plugins/openrouter/openrouter.js +121 -0
  95. package/src/plugins/persona/README.md +20 -0
  96. package/src/plugins/persona/persona.js +9 -16
  97. package/src/plugins/policy/README.md +21 -0
  98. package/src/plugins/policy/policy.js +29 -14
  99. package/src/plugins/prompt/README.md +1 -1
  100. package/src/plugins/prompt/prompt.js +64 -16
  101. package/src/plugins/rm/README.md +1 -1
  102. package/src/plugins/rm/rm.js +56 -12
  103. package/src/plugins/rm/rmDoc.js +2 -20
  104. package/src/plugins/rm/rmDoc.md +13 -0
  105. package/src/plugins/rpc/README.md +2 -2
  106. package/src/plugins/rpc/rpc.js +525 -296
  107. package/src/plugins/set/README.md +1 -1
  108. package/src/plugins/set/set.js +318 -75
  109. package/src/plugins/set/setDoc.js +2 -35
  110. package/src/plugins/set/setDoc.md +22 -0
  111. package/src/plugins/sh/README.md +28 -5
  112. package/src/plugins/sh/sh.js +50 -6
  113. package/src/plugins/sh/shDoc.js +2 -23
  114. package/src/plugins/sh/shDoc.md +13 -0
  115. package/src/plugins/skill/README.md +23 -0
  116. package/src/plugins/skill/skill.js +14 -18
  117. package/src/plugins/stream/README.md +101 -0
  118. package/src/plugins/stream/stream.js +290 -0
  119. package/src/plugins/telemetry/README.md +1 -1
  120. package/src/plugins/telemetry/telemetry.js +129 -80
  121. package/src/plugins/think/README.md +1 -1
  122. package/src/plugins/think/think.js +12 -0
  123. package/src/plugins/think/thinkDoc.js +2 -15
  124. package/src/plugins/think/thinkDoc.md +7 -0
  125. package/src/plugins/unknown/README.md +3 -3
  126. package/src/plugins/unknown/unknown.js +47 -19
  127. package/src/plugins/unknown/unknownDoc.js +2 -21
  128. package/src/plugins/unknown/unknownDoc.md +11 -0
  129. package/src/plugins/update/README.md +1 -1
  130. package/src/plugins/update/update.js +83 -5
  131. package/src/plugins/update/updateDoc.js +2 -30
  132. package/src/plugins/update/updateDoc.md +8 -0
  133. package/src/plugins/xai/README.md +23 -0
  134. package/src/{llm/XaiClient.js → plugins/xai/xai.js} +58 -37
  135. package/src/plugins/yolo/yolo.js +192 -0
  136. package/src/server/ClientConnection.js +64 -37
  137. package/src/server/SocketServer.js +23 -10
  138. package/src/server/protocol.js +11 -0
  139. package/src/sql/v_model_context.sql +27 -31
  140. package/src/sql/v_run_log.sql +9 -14
  141. package/EXCEPTIONS.md +0 -46
  142. package/FIDELITY_CONTRACT.md +0 -172
  143. package/src/agent/KnownStore.js +0 -337
  144. package/src/agent/ResponseHealer.js +0 -241
  145. package/src/llm/OpenAiClient.js +0 -100
  146. package/src/llm/OpenRouterClient.js +0 -100
  147. package/src/plugins/budget/recovery.js +0 -47
  148. package/src/plugins/instructions/preamble.md +0 -45
  149. package/src/plugins/performed/README.md +0 -15
  150. package/src/plugins/performed/performed.js +0 -45
  151. package/src/plugins/previous/README.md +0 -16
  152. package/src/plugins/previous/previous.js +0 -56
  153. package/src/plugins/progress/README.md +0 -16
  154. package/src/plugins/progress/progress.js +0 -43
  155. package/src/plugins/summarize/README.md +0 -19
  156. package/src/plugins/summarize/summarize.js +0 -32
  157. package/src/plugins/summarize/summarizeDoc.js +0 -27
@@ -0,0 +1,8 @@
1
+ ## <update status="N">{brief status}</update> - Status report (exactly one per turn, at the end)
2
+ <!-- Header defines position, frequency, and status code requirement. -->
3
+
4
+ REQUIRED: the valid values of N are defined by your current stage instructions.
5
+ <!-- Single source of truth for codes is the current phase instructions block, not this doc. Listing codes here leaks termination knowledge (e.g. 200) that strong models use to short-circuit the protocol. -->
6
+
7
+ REQUIRED: YOU MUST keep <update></update> body to <= 80 characters.
8
+ <!-- Length cap. -->
@@ -0,0 +1,23 @@
1
+ # xai
2
+
3
+ xAI (Grok) LLM provider. Handles model aliases prefixed with `xai/`
4
+ (e.g. `xai/grok-2`).
5
+
6
+ ## Env
7
+
8
+ - `XAI_BASE_URL` — full responses endpoint (e.g.
9
+ `https://api.x.ai/v1/responses`). Plugin is inert if unset.
10
+ - `XAI_API_KEY` — bearer token.
11
+
12
+ ## Response Normalization
13
+
14
+ xAI's response shape differs from OpenAI's. The plugin walks
15
+ `data.output[]`, collecting text from items of type `message` as
16
+ `content` and items of type `reasoning` as `reasoning_content`, then
17
+ emits the common OpenAI-shaped envelope.
18
+
19
+ ## Context Size
20
+
21
+ Tries `/models` first for a `context_length` field, then the
22
+ `/language-models/<id>` endpoint as a fallback. Results are cached
23
+ per model for the plugin lifetime.
@@ -1,24 +1,46 @@
1
- import msg from "../agent/messages.js";
1
+ import msg from "../../agent/messages.js";
2
2
 
3
- export default class XaiClient {
3
+ const FETCH_TIMEOUT = Number(process.env.RUMMY_FETCH_TIMEOUT);
4
+ if (!FETCH_TIMEOUT) throw new Error("RUMMY_FETCH_TIMEOUT must be set");
5
+
6
+ const PROVIDER = "xai";
7
+
8
+ /**
9
+ * xAI (Grok) LLM provider plugin. Registers with hooks.llm.providers if
10
+ * XAI_BASE_URL is set; inert otherwise. Handles model aliases of the
11
+ * form `xai/{modelName}`. Normalizes xAI's distinct response shape
12
+ * into the common OpenAI-shaped envelope.
13
+ */
14
+ export default class Xai {
4
15
  #baseUrl;
5
16
  #apiKey;
6
17
  #contextCache = new Map();
7
18
 
8
- constructor(baseUrl, apiKey) {
19
+ constructor(core) {
20
+ const baseUrl = process.env.XAI_BASE_URL;
21
+ if (!baseUrl) return;
9
22
  this.#baseUrl = baseUrl;
10
- this.#apiKey = apiKey;
23
+ this.#apiKey = process.env.XAI_API_KEY;
24
+
25
+ const wireModel = (alias) => alias.split("/").slice(1).join("/");
26
+
27
+ core.hooks.llm.providers.push({
28
+ name: PROVIDER,
29
+ matches: (model) => model.split("/")[0] === PROVIDER,
30
+ completion: (messages, model, options) =>
31
+ this.#completion(messages, wireModel(model), options),
32
+ getContextSize: (model) => this.#getContextSize(wireModel(model)),
33
+ });
11
34
  }
12
35
 
13
- async completion(messages, model, options = {}) {
36
+ async #completion(messages, model, options = {}) {
14
37
  if (!this.#apiKey) throw new Error(msg("error.xai_api_key_missing"));
15
38
 
16
39
  const body = { model, input: messages };
17
40
  if (options.temperature !== undefined)
18
41
  body.temperature = options.temperature;
19
42
 
20
- const timeout = Number(process.env.RUMMY_FETCH_TIMEOUT) || 30_000;
21
- const timeoutSignal = AbortSignal.timeout(timeout);
43
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT);
22
44
  const signal = options.signal
23
45
  ? AbortSignal.any([options.signal, timeoutSignal])
24
46
  : timeoutSignal;
@@ -37,29 +59,22 @@ export default class XaiClient {
37
59
  const error = await response.text();
38
60
  if (response.status === 401 || response.status === 403) {
39
61
  throw new Error(
40
- msg("error.xai_auth", {
41
- status: `${response.status} - ${error}`,
42
- }),
62
+ msg("error.xai_auth", { status: `${response.status} - ${error}` }),
43
63
  );
44
64
  }
45
65
  throw new Error(
46
- msg("error.xai_api", {
47
- status: `${response.status} - ${error}`,
48
- }),
66
+ msg("error.xai_api", { status: `${response.status} - ${error}` }),
49
67
  );
50
68
  }
51
69
 
52
- const data = await response.json();
53
- return this.#normalize(data);
70
+ return this.#normalize(await response.json());
54
71
  }
55
72
 
56
73
  #normalize(data) {
57
- const output = data.output || [];
58
-
59
74
  let content = "";
60
75
  let reasoningContent = null;
61
76
 
62
- for (const item of output) {
77
+ for (const item of data.output) {
63
78
  if (item.type === "reasoning") {
64
79
  const text = this.#extractText(item.content);
65
80
  if (text)
@@ -73,9 +88,13 @@ export default class XaiClient {
73
88
  }
74
89
  }
75
90
 
76
- const usage = data.usage || {};
77
- const inputTokens = usage.input_tokens || 0;
78
- const outputTokens = usage.output_tokens || 0;
91
+ const { usage } = data;
92
+ const inputTokens = usage.input_tokens;
93
+ const outputTokens = usage.output_tokens;
94
+ // Optional per xAI API; absent on providers that don't surface them.
95
+ const cached = usage.input_tokens_details?.cached_tokens;
96
+ const reasoningTokens = usage.output_tokens_details?.reasoning_tokens;
97
+ const costTicks = usage.cost_in_usd_ticks;
79
98
  return {
80
99
  choices: [
81
100
  {
@@ -88,11 +107,11 @@ export default class XaiClient {
88
107
  ],
89
108
  usage: {
90
109
  prompt_tokens: inputTokens,
91
- cached_tokens: usage.input_tokens_details?.cached_tokens || 0,
110
+ cached_tokens: cached === undefined ? 0 : cached,
92
111
  completion_tokens: outputTokens,
93
- reasoning_tokens: usage.output_tokens_details?.reasoning_tokens || 0,
112
+ reasoning_tokens: reasoningTokens === undefined ? 0 : reasoningTokens,
94
113
  total_tokens: inputTokens + outputTokens,
95
- cost: (usage.cost_in_usd_ticks || 0) / 10_000_000_000,
114
+ cost: costTicks === undefined ? 0 : costTicks / 10_000_000_000,
96
115
  },
97
116
  };
98
117
  }
@@ -100,29 +119,30 @@ export default class XaiClient {
100
119
  #extractText(content) {
101
120
  if (typeof content === "string") return content;
102
121
  if (!Array.isArray(content)) return null;
103
- return (
104
- content
105
- .filter((c) => c.type === "text" || c.type === "output_text")
106
- .map((c) => c.text)
107
- .join("\n") || null
108
- );
122
+ const joined = content
123
+ .filter((c) => c.type === "text" || c.type === "output_text")
124
+ .map((c) => c.text)
125
+ .join("\n");
126
+ return joined ? joined : null;
109
127
  }
110
128
 
111
- async getContextSize(model) {
129
+ async #getContextSize(model) {
112
130
  if (this.#contextCache.has(model)) return this.#contextCache.get(model);
113
-
114
131
  if (!this.#apiKey) throw new Error(msg("error.xai_api_key_missing"));
115
132
 
116
- // Query xAI models endpoint
117
133
  const modelsUrl = this.#baseUrl.replace(/\/responses$/, "/models");
118
134
  const res = await fetch(modelsUrl, {
119
135
  headers: { Authorization: `Bearer ${this.#apiKey}` },
120
136
  signal: AbortSignal.timeout(5000),
121
137
  });
122
-
123
138
  if (res.ok) {
124
139
  const data = await res.json();
125
- const models = data.data || data.models || [];
140
+ // xAI's /models returns either { data: [...] } or { models: [...] }
141
+ // depending on the API version; accept either and crash otherwise.
142
+ let models;
143
+ if (data.data) models = data.data;
144
+ else if (data.models) models = data.models;
145
+ else throw new Error("xAI /models response has neither data nor models");
126
146
  const entry = models.find(
127
147
  (m) => m.id === model || `${m.id}-latest` === model,
128
148
  );
@@ -132,16 +152,17 @@ export default class XaiClient {
132
152
  }
133
153
  }
134
154
 
135
- // Try /v1/language-models for richer metadata
136
155
  const langUrl = this.#baseUrl.replace(
137
156
  /\/responses$/,
138
157
  `/language-models/${model}`,
139
158
  );
159
+ // Optional endpoint probe. If the network call fails (404 on older
160
+ // API versions, timeout, etc.) we fall through to the next strategy
161
+ // below; a terminal throw fires if no strategy resolves.
140
162
  const langRes = await fetch(langUrl, {
141
163
  headers: { Authorization: `Bearer ${this.#apiKey}` },
142
164
  signal: AbortSignal.timeout(5000),
143
165
  }).catch(() => null);
144
-
145
166
  if (langRes?.ok) {
146
167
  const langData = await langRes.json();
147
168
  if (langData?.context_length) {
@@ -0,0 +1,192 @@
1
+ import { spawn } from "node:child_process";
2
+ import { logPathToDataBase } from "../helpers.js";
3
+
4
+ const SH_PATH_RE = /^log:\/\/turn_\d+\/(sh|env)\//;
5
+
6
+ /**
7
+ * YOLO plugin — for runs started with `yolo: true`, auto-resolves every
8
+ * proposal server-side and spawns sh/env commands locally, streaming
9
+ * output to the same data-channel entries the existing `stream`/
10
+ * `stream/completed` RPC contract uses.
11
+ *
12
+ * Pattern parallel to `noRepo`/`noWeb`/`noInteraction`/`noProposals`:
13
+ * `yolo` is a run attribute plumbed via rpc.js → AgentLoop loop config →
14
+ * RummyContext.yolo. This plugin reads `rummy.yolo` off the proposal
15
+ * payload and engages only when set; non-yolo runs are unaffected.
16
+ *
17
+ * The plugin replicates AgentLoop.resolve()'s accept path inline rather
18
+ * than calling an exposed projectAgent — keeps yolo logic contained in
19
+ * the yolo plugin and out of backbone files.
20
+ */
21
+ export default class Yolo {
22
+ constructor(core) {
23
+ this.core = core;
24
+ core.hooks.proposal.pending.on(this.#onPending.bind(this));
25
+ }
26
+
27
+ async #onPending({ run, proposed, rummy }) {
28
+ if (!rummy?.yolo) return;
29
+ for (const p of proposed) {
30
+ // Resolve first — that fires proposal.accepted, which lets the
31
+ // sh/env plugin seed the streaming channel entries. Then spawn
32
+ // into those existing channels. If we spawned first, sh.js's
33
+ // post-accept channel creation would clobber the body we just
34
+ // streamed (sets state=streaming, body="").
35
+ await this.#serverResolve(rummy, p.path);
36
+ if (SH_PATH_RE.test(p.path)) {
37
+ await this.#executeShellProposal(rummy, p.path);
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Replicate AgentLoop.resolve()'s accept path: accepting filter
44
+ * (veto check), content filter (resolved body), set state="resolved",
45
+ * emit proposal.accepted for plugin side effects.
46
+ */
47
+ async #serverResolve(rummy, path) {
48
+ const runId = rummy.runId;
49
+ const entries = rummy.entries;
50
+ const db = rummy.db;
51
+ const runRow = await db.get_run_by_id.get({ id: runId });
52
+ const project = await db.get_project_by_id.get({ id: runRow.project_id });
53
+ const attrs = await entries.getAttributes(runId, path);
54
+ const ctx = {
55
+ runId,
56
+ runRow,
57
+ projectId: runRow.project_id,
58
+ projectRoot: project?.project_root,
59
+ path,
60
+ attrs,
61
+ output: "",
62
+ db,
63
+ entries,
64
+ };
65
+
66
+ const veto = await this.core.hooks.proposal.accepting.filter(null, ctx);
67
+ if (veto?.allow === false) {
68
+ await entries.set({
69
+ runId,
70
+ path,
71
+ state: "failed",
72
+ outcome: veto.outcome,
73
+ body: veto.body,
74
+ });
75
+ return;
76
+ }
77
+
78
+ const resolvedBody = await this.core.hooks.proposal.content.filter("", ctx);
79
+ const existing = await entries.getState(runId, path);
80
+ const existingTurn = existing?.turn === undefined ? 0 : existing.turn;
81
+ await entries.set({
82
+ runId,
83
+ turn: existingTurn,
84
+ path,
85
+ state: "resolved",
86
+ body: resolvedBody,
87
+ });
88
+ await this.core.hooks.proposal.accepted.emit({ ...ctx, resolvedBody });
89
+ }
90
+
91
+ /**
92
+ * Spawn the sh/env command locally and stream stdout/stderr into
93
+ * `{dataBase}_1` and `{dataBase}_2` data entries. Mirrors the
94
+ * stream/stream-completed RPC contract — same channel layout, same
95
+ * terminal-state transitions on exit. Done inline (no RPC roundtrip)
96
+ * so the run is fully autonomous.
97
+ */
98
+ async #executeShellProposal(rummy, logPath) {
99
+ const runId = rummy.runId;
100
+ const entries = rummy.entries;
101
+ const db = rummy.db;
102
+ const runRow = await db.get_run_by_id.get({ id: runId });
103
+ const project = await db.get_project_by_id.get({ id: runRow.project_id });
104
+ const projectRoot = project?.project_root;
105
+ if (!projectRoot) return;
106
+
107
+ const attrs = await entries.getAttributes(runId, logPath);
108
+ const command = attrs?.command || attrs?.summary;
109
+ if (!command) return;
110
+
111
+ const dataBase = logPathToDataBase(logPath);
112
+ if (!dataBase) return;
113
+ const stdoutPath = `${dataBase}_1`;
114
+ const stderrPath = `${dataBase}_2`;
115
+
116
+ const start = Date.now();
117
+ const child = spawn("bash", ["-lc", command], {
118
+ cwd: projectRoot,
119
+ env: process.env,
120
+ });
121
+ // Buffer chunks synchronously and write once after exit. Avoids
122
+ // the race where multiple async appends interleave with the
123
+ // terminal-state transition fired on 'close'.
124
+ const stdoutChunks = [];
125
+ const stderrChunks = [];
126
+ child.stdout.on("data", (data) => stdoutChunks.push(data.toString()));
127
+ child.stderr.on("data", (data) => stderrChunks.push(data.toString()));
128
+
129
+ await new Promise((resolve) => {
130
+ child.on("close", async (code) => {
131
+ const stdoutBody = stdoutChunks.join("");
132
+ const stderrBody = stderrChunks.join("");
133
+ if (stdoutBody) {
134
+ try {
135
+ await entries.set({
136
+ runId,
137
+ path: stdoutPath,
138
+ body: stdoutBody,
139
+ append: true,
140
+ });
141
+ } catch {}
142
+ }
143
+ if (stderrBody) {
144
+ try {
145
+ await entries.set({
146
+ runId,
147
+ path: stderrPath,
148
+ body: stderrBody,
149
+ append: true,
150
+ });
151
+ } catch {}
152
+ }
153
+ const exitCode = code === null ? 130 : code;
154
+ const duration = `${Math.round((Date.now() - start) / 1000)}s`;
155
+ const terminalState = exitCode === 0 ? "resolved" : "failed";
156
+ const outcome = exitCode === 0 ? null : `exit:${exitCode}`;
157
+ // Transition state without touching body — getState doesn't
158
+ // return body, and entries.set with body=undefined preserves
159
+ // the streamed content already in place. (`body: ""` would
160
+ // wipe everything we just streamed.)
161
+ for (const path of [stdoutPath, stderrPath]) {
162
+ try {
163
+ await entries.set({
164
+ runId,
165
+ path,
166
+ state: terminalState,
167
+ outcome,
168
+ });
169
+ } catch {}
170
+ }
171
+ try {
172
+ const channels = await entries.getEntriesByPattern(
173
+ runId,
174
+ `${dataBase}_*`,
175
+ null,
176
+ );
177
+ const summary = channels
178
+ .map((c) => `${c.path} (${c.tokens || 0} tokens)`)
179
+ .join(", ");
180
+ const exitLabel = exitCode === 0 ? "exit=0" : `exit=${exitCode}`;
181
+ await entries.set({
182
+ runId,
183
+ path: logPath,
184
+ state: "resolved",
185
+ body: `ran '${command}', ${exitLabel} (${duration}). Output: ${summary}`,
186
+ });
187
+ } catch {}
188
+ resolve();
189
+ });
190
+ });
191
+ }
192
+ }
@@ -8,6 +8,7 @@ export default class ClientConnection {
8
8
  #hooks;
9
9
  #rpcRegistry;
10
10
  #rpcLogPending = new Map();
11
+ #shutdownPromise = null;
11
12
  #context = {
12
13
  projectId: null,
13
14
  projectRoot: null,
@@ -21,7 +22,13 @@ export default class ClientConnection {
21
22
  this.#projectAgent = new ProjectAgent(db, hooks);
22
23
 
23
24
  this.#ws.on("message", (data) => this.#handleMessage(data));
24
- this.#ws.on("close", () => this.#teardown());
25
+ this.#ws.on("close", () => {
26
+ // Fire-and-forget: the Promise is cached by `shutdown()` so
27
+ // server-initiated close can await the same work.
28
+ this.shutdown().catch((err) => {
29
+ console.warn(`[RUMMY] shutdown on ws close failed: ${err.message}`);
30
+ });
31
+ });
25
32
 
26
33
  this.#setupNotifications();
27
34
  }
@@ -63,6 +70,16 @@ export default class ClientConnection {
63
70
  }
64
71
  };
65
72
 
73
+ #onStreamCancelled = (payload) => {
74
+ if (payload.projectId === this.#context.projectId) {
75
+ this.#sendNotification("stream/cancelled", {
76
+ run: payload.run,
77
+ path: payload.path,
78
+ reason: payload.reason,
79
+ });
80
+ }
81
+ };
82
+
66
83
  #onState = (payload) => {
67
84
  if (payload.projectId === this.#context.projectId) {
68
85
  this.#sendNotification("run/state", {
@@ -79,18 +96,37 @@ export default class ClientConnection {
79
96
 
80
97
  #setupNotifications() {
81
98
  this.#hooks.run.progress.on(this.#onProgress);
82
- this.#hooks.turn.proposal.on(this.#onProposal);
99
+ this.#hooks.proposal.pending.on(this.#onProposal);
83
100
  this.#hooks.ui.render.on(this.#onRender);
84
101
  this.#hooks.ui.notify.on(this.#onNotify);
85
102
  this.#hooks.run.state.on(this.#onState);
103
+ this.#hooks.stream.cancelled.on(this.#onStreamCancelled);
86
104
  }
87
105
 
88
106
  #teardown() {
89
107
  this.#hooks.run.progress.off(this.#onProgress);
90
- this.#hooks.turn.proposal.off(this.#onProposal);
108
+ this.#hooks.proposal.pending.off(this.#onProposal);
91
109
  this.#hooks.ui.render.off(this.#onRender);
92
110
  this.#hooks.ui.notify.off(this.#onNotify);
93
111
  this.#hooks.run.state.off(this.#onState);
112
+ this.#hooks.stream.cancelled.off(this.#onStreamCancelled);
113
+ }
114
+
115
+ /**
116
+ * Abort in-flight runs on this connection and wait for them to
117
+ * settle. Idempotent: `ws.on("close")` and server-initiated close
118
+ * both call this; the cached Promise guarantees the work happens
119
+ * exactly once and both callers observe the same completion.
120
+ */
121
+ shutdown() {
122
+ if (!this.#shutdownPromise) {
123
+ this.#shutdownPromise = (async () => {
124
+ await this.#projectAgent.shutdown();
125
+ this.#teardown();
126
+ if (this.#ws.readyState === 1) this.#ws.terminate();
127
+ })();
128
+ }
129
+ return this.#shutdownPromise;
94
130
  }
95
131
 
96
132
  #buildHandlerContext() {
@@ -113,11 +149,8 @@ export default class ClientConnection {
113
149
 
114
150
  async #handleMessage(data) {
115
151
  let id = null;
116
- const debug = process.env.RUMMY_DEBUG === "true";
117
152
  try {
118
153
  const rawMessage = await this.#hooks.socket.message.raw.filter(data);
119
- if (debug) console.log(`[SOCKET] IN: ${rawMessage.toString()}`);
120
-
121
154
  const message = JSON.parse(rawMessage.toString());
122
155
 
123
156
  const filteredRequest = await this.#hooks.rpc.request.filter(message);
@@ -131,15 +164,13 @@ export default class ClientConnection {
131
164
  projectId: this.#context.projectId,
132
165
  });
133
166
 
134
- try {
135
- const logRow = await this.#db.log_rpc_call.get({
136
- project_id: this.#context.projectId ?? null,
137
- method,
138
- rpc_id: id,
139
- params: params ? JSON.stringify(params) : null,
140
- });
141
- if (logRow) this.#rpcLogPending.set(id, logRow.id);
142
- } catch {}
167
+ const logRow = await this.#db.log_rpc_call.get({
168
+ project_id: this.#context.projectId,
169
+ method,
170
+ rpc_id: id,
171
+ params: params ? JSON.stringify(params) : null,
172
+ });
173
+ if (logRow) this.#rpcLogPending.set(id, logRow.id);
143
174
 
144
175
  const resolvedMethod = method === "rpc/discover" ? "discover" : method;
145
176
  const registration = this.#rpcRegistry.get(resolvedMethod);
@@ -150,17 +181,19 @@ export default class ClientConnection {
150
181
  throw new Error(msg("error.not_initialized"));
151
182
  }
152
183
 
184
+ // JSON-RPC requests may omit `params` entirely.
185
+ const handlerParams = params === undefined ? {} : params;
153
186
  let result;
154
187
  if (registration.longRunning) {
155
188
  result = await registration.handler(
156
- params || {},
189
+ handlerParams,
157
190
  this.#buildHandlerContext(),
158
191
  );
159
192
  } else {
160
- const timeout = Number(process.env.RUMMY_RPC_TIMEOUT) || 10_000;
193
+ const timeout = Number(process.env.RUMMY_RPC_TIMEOUT);
161
194
  let timer;
162
195
  result = await Promise.race([
163
- registration.handler(params || {}, this.#buildHandlerContext()),
196
+ registration.handler(handlerParams, this.#buildHandlerContext()),
164
197
  new Promise((_, reject) => {
165
198
  timer = setTimeout(
166
199
  () =>
@@ -198,43 +231,37 @@ export default class ClientConnection {
198
231
  const logId = this.#rpcLogPending.get(id);
199
232
  if (logId) {
200
233
  this.#rpcLogPending.delete(id);
201
- try {
202
- await this.#db.log_rpc_result.run({
203
- id: logId,
204
- result: finalResult
205
- ? JSON.stringify(finalResult).slice(0, 4096)
206
- : null,
207
- });
208
- } catch {}
234
+ await this.#db.log_rpc_result.run({
235
+ id: logId,
236
+ result: finalResult
237
+ ? JSON.stringify(finalResult).slice(0, 4096)
238
+ : null,
239
+ });
209
240
  }
210
241
  } catch (error) {
211
242
  console.error(`[RUMMY] RPC Error: ${error.message}`);
212
243
  console.error(`[RUMMY] Stack: ${error.stack}`);
244
+ // JSON-RPC: error responses for malformed requests with no id
245
+ // MUST carry null per the spec.
213
246
  this.#send({
214
247
  jsonrpc: "2.0",
215
248
  error: { code: -32603, message: error.message },
216
- id: id || null,
249
+ id: id === undefined ? null : id,
217
250
  });
218
251
  await this.#hooks.rpc.error.emit({ id, error });
219
252
 
220
253
  const errLogId = this.#rpcLogPending.get(id);
221
254
  if (errLogId) {
222
255
  this.#rpcLogPending.delete(id);
223
- try {
224
- await this.#db.log_rpc_error.run({
225
- id: errLogId,
226
- error: error.message,
227
- });
228
- } catch {}
256
+ await this.#db.log_rpc_error.run({
257
+ id: errLogId,
258
+ error: error.message,
259
+ });
229
260
  }
230
261
  }
231
262
  }
232
263
 
233
264
  #send(payload) {
234
- const debug = process.env.RUMMY_DEBUG === "true";
235
- if (debug) {
236
- console.log(`[SOCKET] OUT: ${JSON.stringify(payload, null, 2)}`);
237
- }
238
265
  if (this.#ws.readyState === 1) {
239
266
  this.#ws.send(JSON.stringify(payload));
240
267
  }
@@ -5,17 +5,23 @@ export default class SocketServer {
5
5
  #db;
6
6
  #wss;
7
7
  #hooks;
8
+ #connections = new Set();
8
9
 
9
10
  constructor(db, options) {
10
11
  this.#db = db;
11
12
  this.#hooks = options.hooks;
12
13
  this.#wss = new WebSocketServer(options);
13
14
 
14
- this.#wss.on("connection", (ws, req) => {
15
- if (process.env.RUMMY_DEBUG === "true") {
16
- console.log(`[SOCKET] New connection from ${req.socket.remoteAddress}`);
17
- }
18
- new ClientConnection(ws, this.#db, this.#hooks);
15
+ this.#wss.on("connection", (ws, _req) => {
16
+ const conn = new ClientConnection(ws, this.#db, this.#hooks);
17
+ this.#connections.add(conn);
18
+ // Remove from the tracking set only after the connection's
19
+ // shutdown drain has fully settled — not on raw ws-close
20
+ // so server close() can still find and await an in-progress
21
+ // shutdown kicked off by a client-initiated disconnect.
22
+ ws.on("close", () => {
23
+ conn.shutdown().finally(() => this.#connections.delete(conn));
24
+ });
19
25
  });
20
26
 
21
27
  this.#wss.on("error", (_err) => {
@@ -31,12 +37,19 @@ export default class SocketServer {
31
37
  this.#wss.on(event, handler);
32
38
  }
33
39
 
34
- close() {
35
- return new Promise((resolve) => {
40
+ async close() {
41
+ // Drain in-flight runs on each connection before closing the
42
+ // socket — otherwise detached kickoff Promises keep the Node
43
+ // event loop alive past server shutdown.
44
+ const shutdowns = [];
45
+ for (const conn of this.#connections) {
46
+ shutdowns.push(conn.shutdown().catch(() => {}));
47
+ }
48
+ await Promise.all(shutdowns);
49
+ this.#connections.clear();
50
+
51
+ await new Promise((resolve) => {
36
52
  if (!this.#wss) return resolve();
37
- for (const client of this.#wss.clients) {
38
- client.terminate();
39
- }
40
53
  this.#wss.close(resolve);
41
54
  });
42
55
  }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Server↔client wire-protocol version. Bumped whenever the RPC shape
3
+ * or notification payload shape changes in a way that breaks existing
4
+ * clients. Clients pass their own version in `rummy/hello`; server
5
+ * rejects MAJOR mismatch. Git commit log is the changelog.
6
+ *
7
+ * MAJOR — breaking change (removed/renamed method, shape change)
8
+ * MINOR — additive change (new method, new optional field)
9
+ * PATCH — internal fix visible to the wire shape
10
+ */
11
+ export const RUMMY_PROTOCOL_VERSION = "2.0.0";