@spunto/build 0.11.0 → 0.12.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.
package/README.md CHANGED
@@ -12,6 +12,8 @@ on*.
12
12
  npm install @spunto/build
13
13
  ```
14
14
 
15
+ **Documentation: https://spunto.net/documentation/packages/build**
16
+
15
17
  ## What's in it
16
18
 
17
19
  ### `@spunto/build/steps` — the build-log protocol
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spunto/build",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Spunto's shared Build engine — the devcontainer image protocol and VS Code extension registry clients, with no database, no HTTP framework and no UI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -122,6 +122,37 @@ export function interruptLine(protocol: AgentProtocol, requestId: string): strin
122
122
  return `${JSON.stringify({ type: "control_request", request_id: requestId, request: { subtype: "interrupt" } })}\n`
123
123
  }
124
124
 
125
+ /**
126
+ * Ask the harness to **name the conversation**, or null for a dialect that cannot be asked.
127
+ *
128
+ * Claude Code used to do this on its own within seconds of the first turn, headless included —
129
+ * that title is what `sessionTitleProbe` reads. Measured on 2.1.296 (October 2026), it no longer
130
+ * does for a session driven over `--input-format stream-json`: the transcript gets `atis-latch`
131
+ * and `last-prompt` lines and never an `ai-title`, so every task kept its placeholder. What it
132
+ * still answers is the SDK's own `generate_session_title` control request — the call its
133
+ * TypeScript SDK makes — with `{"title":…}` in a `control_response` on stdout, and `persist`
134
+ * writes the same `ai-title` line to the transcript as before.
135
+ *
136
+ * Asking is not naming: the harness still picks the words, from the human's prompt (the
137
+ * `description`), exactly as it would have unprompted. Nothing of ours ends up in the title.
138
+ *
139
+ * The request id carries `TITLE_REQUEST_PREFIX` so the answer can be told apart from every other
140
+ * `control_response` (an interrupt's, say) without keeping state between write and read.
141
+ */
142
+ export function sessionTitleRequestLine(protocol: AgentProtocol, requestId: string, description: string): string | null {
143
+ if (protocol !== "claude-stream") return null
144
+ const text = description.trim()
145
+ if (!text) return null
146
+ return `${JSON.stringify({
147
+ type: "control_request",
148
+ request_id: `${TITLE_REQUEST_PREFIX}${requestId}`,
149
+ request: { subtype: "generate_session_title", description: text, persist: true },
150
+ })}\n`
151
+ }
152
+
153
+ /** Marks the `control_request`s that ask for a title, and so the `control_response`s that answer. */
154
+ const TITLE_REQUEST_PREFIX = "spunto-title-"
155
+
125
156
  /**
126
157
  * A shell that prints the line where the harness wrote **the name it gave the conversation**, or
127
158
  * null for a dialect that has no such thing.
@@ -143,6 +174,9 @@ export function interruptLine(protocol: AgentProtocol, requestId: string): strin
143
174
  * `--no-session-persistence` writes no transcript, and then this finds nothing — which is a
144
175
  * normal answer, not an error.
145
176
  *
177
+ * Since Claude Code 2.1.296 an interactive session only writes that line once asked
178
+ * (`sessionTitleRequestLine`); a one-shot `claude-json` run has no stdin to be asked on.
179
+ *
146
180
  * `jsonl` gets no probe on purpose: a harness speaking our vocabulary says the name in band,
147
181
  * `{"type":"session.title","title":"…"}`, and pays nothing for it.
148
182
  */
@@ -383,6 +417,16 @@ function adaptClaudeJson(obj: Record<string, unknown>): NormalizedEvent[] {
383
417
  return events
384
418
  }
385
419
 
420
+ // The answer to `sessionTitleRequestLine`. Only ours, recognised by request id: a
421
+ // `control_response` is also how an interrupt is acknowledged, and that one is no name.
422
+ if (type === "control_response") {
423
+ const response = (obj.response ?? {}) as Record<string, unknown>
424
+ const inner = (response.response ?? {}) as Record<string, unknown>
425
+ const requestId = typeof response.request_id === "string" ? response.request_id : ""
426
+ const title = response.subtype === "success" && requestId.startsWith(TITLE_REQUEST_PREFIX) ? cleanTitle(inner.title) : null
427
+ if (title) return [{ type: "session.title", payload: { title } }]
428
+ }
429
+
386
430
  // Known-but-uninteresting (`rate_limit_event`, `system/thinking_tokens`) and anything the CLI
387
431
  // grows next land here rather than being dropped: an unknown event is information about the
388
432
  // harness, and the UI keeps it behind a toggle.
@@ -25,6 +25,7 @@ export {
25
25
  parseStreamLine,
26
26
  readsTitleOutOfBand,
27
27
  sessionTitleProbe,
28
+ sessionTitleRequestLine,
28
29
  splitCompleteLines,
29
30
  summarizeUsage,
30
31
  TASK_EVENT_TYPES,