@gtc6244/immediacy-daemon 0.2.5 → 0.2.7

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.
@@ -91,6 +91,8 @@ export class Mux {
91
91
  highWaterMark;
92
92
  reassembler = new Reassembler();
93
93
  seqs = new Map();
94
+ /** Tail of the per-channel send chain — serializes concurrent sends. */
95
+ sendChains = new Map();
94
96
  onMsg = null;
95
97
  constructor(transport, opts) {
96
98
  this.transport = transport;
@@ -119,18 +121,45 @@ export class Mux {
119
121
  * Send a logical message on `channelId`, chunked as needed. Resolves once all
120
122
  * chunks are handed to the transport; awaits drain between chunks so a large
121
123
  * message (or a terminal firehose) can't grow the queue without bound.
124
+ *
125
+ * Sends on the same channel are SERIALIZED: `send` awaits `whenWritable`
126
+ * mid-message, so a second concurrent `send` (e.g. a heartbeat pong while a
127
+ * large response is still draining) would otherwise interleave its frames and
128
+ * reuse the shared `seq` counter — the peer's reassembler mixes the two
129
+ * messages and its SecureChannel receive counter desyncs, breaking the session.
130
+ * The per-channel promise chain guarantees one message's frames are fully
131
+ * written (and `seq` advanced) before the next message on that channel starts.
122
132
  */
123
- async send(channelId, message) {
133
+ send(channelId, message) {
134
+ const prev = this.sendChains.get(channelId) ?? Promise.resolve();
135
+ // `prev.catch` so one failed send never poisons the ones queued behind it.
136
+ const next = prev.catch(() => { }).then(() => this.sendChunks(channelId, message));
137
+ this.sendChains.set(channelId, next);
138
+ // Drop the chain entry once it drains to the tail, so an idle channel's map
139
+ // entry doesn't pin the last message's promise forever.
140
+ void next.catch(() => { }).finally(() => {
141
+ if (this.sendChains.get(channelId) === next)
142
+ this.sendChains.delete(channelId);
143
+ });
144
+ return next;
145
+ }
146
+ async sendChunks(channelId, message) {
124
147
  let seq = this.seqs.get(channelId) ?? 0;
125
- const frames = splitMessage(channelId, seq, message);
126
- for (const frame of frames) {
127
- this.transport.send(encodeFrame(frame));
128
- seq = (frame.seq + 1) >>> 0;
129
- if (this.transport.bufferedAmount() >= this.highWaterMark) {
130
- await this.transport.whenWritable(this.highWaterMark);
148
+ try {
149
+ for (const frame of splitMessage(channelId, seq, message)) {
150
+ this.transport.send(encodeFrame(frame));
151
+ seq = (frame.seq + 1) >>> 0;
152
+ if (this.transport.bufferedAmount() >= this.highWaterMark) {
153
+ await this.transport.whenWritable(this.highWaterMark);
154
+ }
131
155
  }
132
156
  }
133
- this.seqs.set(channelId, seq);
157
+ finally {
158
+ // Persist progress even on a mid-message throw, so the next queued message
159
+ // never reuses seq numbers already emitted on the wire (which would collide
160
+ // at the peer's reassembler).
161
+ this.seqs.set(channelId, seq);
162
+ }
134
163
  }
135
164
  }
136
165
  //# sourceMappingURL=transport.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/transport.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EACL,WAAW,EACX,WAAW,EACX,YAAY,EACZ,WAAW,GACZ,MAAM,cAAc,CAAC;AAmBtB,2DAA2D;AAC3D,MAAM,CAAC,MAAM,kBAAkB,GAAG,IAAI,GAAG,IAAI,CAAC;AAE9C;;;;;GAKG;AACH,MAAM,OAAO,iBAAiB;IACpB,IAAI,CAAqB;IACzB,OAAO,GAAqC,IAAI,CAAC;IACjD,OAAO,GAAwB,IAAI,CAAC;IACpC,QAAQ,GAAG,CAAC,CAAC;IACb,MAAM,GAAG,KAAK,CAAC;IACf,YAAY,GAA+C,EAAE,CAAC;IAEtE,MAAM,CAAC,IAAI;QACT,MAAM,CAAC,GAAG,IAAI,iBAAiB,EAAE,CAAC;QAClC,MAAM,CAAC,GAAG,IAAI,iBAAiB,EAAE,CAAC;QAClC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC;QACX,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC;QACX,OAAO,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAChB,CAAC;IAED,IAAI,CAAC,KAAa;QAChB,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO;QACxB,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAChC,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,MAAM,CAAC;QAC7B,4EAA4E;QAC5E,cAAc,CAAC,GAAG,EAAE;YAClB,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,CAAC;YAC1B,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,MAAM,CAAC;YAC7B,IAAI,CAAC,gBAAgB,EAAE,CAAC;QAC1B,CAAC,CAAC,CAAC;IACL,CAAC;IAED,cAAc;QACZ,OAAO,IAAI,CAAC,QAAQ,CAAC;IACvB,CAAC;IAED,OAAO,CAAC,EAA2B;QACjC,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;IACpB,CAAC;IAED,OAAO,CAAC,EAAc;QACpB,IAAI,CAAC,OAAO,GAAG,EAAE,CAAC;IACpB,CAAC;IAED,YAAY,CAAC,aAAqB;QAChC,IAAI,IAAI,CAAC,QAAQ,GAAG,aAAa;YAAE,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;QAC5D,OAAO,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;YACnC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,EAAE,EAAE,EAAE,aAAa,EAAE,OAAO,EAAE,CAAC,CAAC;QACzD,CAAC,CAAC,CAAC;IACL,CAAC;IAEO,gBAAgB;QACtB,IAAI,IAAI,CAAC,YAAY,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAC3C,MAAM,KAAK,GAA6B,EAAE,CAAC;QAC3C,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,YAAY,EAAE,CAAC;YAClC,IAAI,IAAI,CAAC,QAAQ,GAAG,CAAC,CAAC,EAAE;gBAAE,CAAC,CAAC,OAAO,EAAE,CAAC;;gBACjC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACrB,CAAC;QACD,IAAI,CAAC,YAAY,GAAG,KAAK,CAAC;IAC5B,CAAC;IAED,KAAK;QACH,IAAI,IAAI,CAAC,MAAM;YAAE,OAAO;QACxB,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;QACnB,KAAK,MAAM,CAAC,IAAI,IAAI,CAAC,YAAY;YAAE,CAAC,CAAC,OAAO,EAAE,CAAC;QAC/C,IAAI,CAAC,YAAY,GAAG,EAAE,CAAC;QACvB,cAAc,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAC9C,CAAC;CACF;AAED;;;GAGG;AACH,MAAM,OAAO,GAAG;IASK;IARF,aAAa,CAAS;IACtB,WAAW,GAAG,IAAI,WAAW,EAAE,CAAC;IAChC,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IAClD,wEAAwE;IACvD,UAAU,GAAG,IAAI,GAAG,EAAyB,CAAC;IACvD,KAAK,GAA0D,IAAI,CAAC;IAE5E,YACmB,SAAoB,EACrC,IAAiC;QADhB,cAAS,GAAT,SAAS,CAAW;QAGrC,IAAI,CAAC,aAAa,GAAG,IAAI,EAAE,aAAa,IAAI,kBAAkB,CAAC;QAC/D,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,KAAK,EAAE,EAAE;YAC/B,IAAI,GAAkD,CAAC;YACvD,IAAI,CAAC;gBACH,GAAG,GAAG,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC;YAClD,CAAC;YAAC,MAAM,CAAC;gBACP,OAAO,CAAC,uDAAuD;YACjE,CAAC;YACD,IAAI,GAAG;gBAAE,IAAI,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;QACpD,CAAC,CAAC,CAAC;IACL,CAAC;IAED,gDAAgD;IAChD,SAAS,CAAC,EAAgD;QACxD,IAAI,CAAC,KAAK,GAAG,EAAE,CAAC;IAClB,CAAC;IAED,oEAAoE;IACpE,QAAQ;QACN,OAAO,IAAI,CAAC,SAAS,CAAC,cAAc,EAAE,GAAG,IAAI,CAAC,aAAa,CAAC;IAC9D,CAAC;IAED;;;;;;;;;;;;OAYG;IACH,IAAI,CAAC,SAAiB,EAAE,OAAe;QACrC,MAAM,IAAI,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC;QACjE,2EAA2E;QAC3E,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC,CAAC;QAClF,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;QACrC,4EAA4E;QAC5E,wDAAwD;QACxD,KAAK,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,EAAE;YACrC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,SAAS,CAAC,KAAK,IAAI;gBAAE,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QACjF,CAAC,CAAC,CAAC;QACH,OAAO,IAAI,CAAC;IACd,CAAC;IAEO,KAAK,CAAC,UAAU,CAAC,SAAiB,EAAE,OAAe;QACzD,IAAI,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QACxC,IAAI,CAAC;YACH,KAAK,MAAM,KAAK,IAAI,YAAY,CAAC,SAAS,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,CAAC;gBAC1D,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC;gBACxC,GAAG,GAAG,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;gBAC5B,IAAI,IAAI,CAAC,SAAS,CAAC,cAAc,EAAE,IAAI,IAAI,CAAC,aAAa,EAAE,CAAC;oBAC1D,MAAM,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;gBACxD,CAAC;YACH,CAAC;QACH,CAAC;gBAAS,CAAC;YACT,2EAA2E;YAC3E,4EAA4E;YAC5E,8BAA8B;YAC9B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;QAChC,CAAC;IACH,CAAC;CACF"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Turn summarizer — condenses one agent turn (everything since the last user
3
+ * prompt) into a few markdown bullet points via a single cheap LLM call.
4
+ *
5
+ * Powers the condensed chat view: when the agent stops (done / awaiting a
6
+ * permission / asking a question), the workspace manager builds the turn slice
7
+ * and calls {@link generateTurnSummary}, persists the result as a `summary`
8
+ * ChatEntry, and broadcasts `event.turn_summary`. Clients in condensed view
9
+ * collapse the turn to these bullets with a button to expand back to full detail.
10
+ *
11
+ * Mirrors `titler.ts`: same vendored Claude Agent SDK, same cheap pinned model,
12
+ * tools disabled (a pure completion), best-effort with a null fallback so a slow
13
+ * or failing model call never wedges a turn. The pure helpers
14
+ * (`buildTurnSlice` / `renderSummaryPrompt` / `hasSummarizableContent`) are
15
+ * separated out so they are unit-testable without the LLM.
16
+ */
17
+ import type { ChatEntry } from "./protocol.js";
18
+ /** Cheapest current model — a handful of bullets needs nothing larger. */
19
+ export declare const SUMMARY_MODEL = "claude-haiku-4-5";
20
+ /** The `seq` of the most recent `user` prompt (the start of the latest turn). */
21
+ export declare function lastUserSeq(entries: ChatEntry[]): number | null;
22
+ /**
23
+ * The entries that make up one turn: everything AFTER the `user` prompt with
24
+ * `seq === turnStartSeq`, up to (but not including) the next `user` prompt.
25
+ * Prior summaries and hidden rename records are dropped. Empty if the start
26
+ * prompt isn't found.
27
+ */
28
+ export declare function buildTurnSlice(entries: ChatEntry[], turnStartSeq: number): ChatEntry[];
29
+ /** Highest `seq` present in a slice (−1 if none) — the turn's content watermark. */
30
+ export declare function sliceContentSeq(slice: ChatEntry[]): number;
31
+ /** True when a slice has any agent output worth summarizing. */
32
+ export declare function hasSummarizableContent(slice: ChatEntry[]): boolean;
33
+ /** Render a turn slice into the model prompt (pure — no I/O). */
34
+ export declare function renderSummaryPrompt(slice: ChatEntry[]): string;
35
+ /**
36
+ * Produce a markdown bullet summary for one turn. Returns null on an empty
37
+ * slice, an empty result, or any error, so callers leave the turn un-condensed
38
+ * (shown in full) rather than blocking.
39
+ */
40
+ export declare function generateTurnSummary(slice: ChatEntry[], sdkEnv?: Record<string, string | undefined>): Promise<string | null>;
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Turn summarizer — condenses one agent turn (everything since the last user
3
+ * prompt) into a few markdown bullet points via a single cheap LLM call.
4
+ *
5
+ * Powers the condensed chat view: when the agent stops (done / awaiting a
6
+ * permission / asking a question), the workspace manager builds the turn slice
7
+ * and calls {@link generateTurnSummary}, persists the result as a `summary`
8
+ * ChatEntry, and broadcasts `event.turn_summary`. Clients in condensed view
9
+ * collapse the turn to these bullets with a button to expand back to full detail.
10
+ *
11
+ * Mirrors `titler.ts`: same vendored Claude Agent SDK, same cheap pinned model,
12
+ * tools disabled (a pure completion), best-effort with a null fallback so a slow
13
+ * or failing model call never wedges a turn. The pure helpers
14
+ * (`buildTurnSlice` / `renderSummaryPrompt` / `hasSummarizableContent`) are
15
+ * separated out so they are unit-testable without the LLM.
16
+ */
17
+ import { query } from "@anthropic-ai/claude-agent-sdk";
18
+ import { logger } from "./log.js";
19
+ /** Cheapest current model — a handful of bullets needs nothing larger. */
20
+ export const SUMMARY_MODEL = "claude-haiku-4-5";
21
+ const INSTRUCTION = "You are summarizing one turn of an AI coding agent's work for a compact " +
22
+ "mobile chat view. Given the turn transcript below, write 2-5 short markdown " +
23
+ 'bullet points (each line starting with "- ") describing what the agent ' +
24
+ "actually did this turn: concrete actions, files changed, commands run, " +
25
+ "decisions made, and — if it stopped — what it is now waiting on (a " +
26
+ "permission or a question). Be terse and specific. Output ONLY the bullet " +
27
+ "list: no preamble, no headings, no closing remarks.\n\nTurn transcript:\n\n";
28
+ /** Per-entry and whole-prompt caps — a longer paste doesn't make better bullets. */
29
+ const MAX_ENTRY_CHARS = 600;
30
+ const MAX_PROMPT_CHARS = 8000;
31
+ /** The `seq` of the most recent `user` prompt (the start of the latest turn). */
32
+ export function lastUserSeq(entries) {
33
+ for (let i = entries.length - 1; i >= 0; i--) {
34
+ const e = entries[i];
35
+ if (e.role === "user" && typeof e.seq === "number")
36
+ return e.seq;
37
+ }
38
+ return null;
39
+ }
40
+ /**
41
+ * The entries that make up one turn: everything AFTER the `user` prompt with
42
+ * `seq === turnStartSeq`, up to (but not including) the next `user` prompt.
43
+ * Prior summaries and hidden rename records are dropped. Empty if the start
44
+ * prompt isn't found.
45
+ */
46
+ export function buildTurnSlice(entries, turnStartSeq) {
47
+ const start = entries.findIndex((e) => e.role === "user" && e.seq === turnStartSeq);
48
+ if (start < 0)
49
+ return [];
50
+ const slice = [];
51
+ for (let i = start + 1; i < entries.length; i++) {
52
+ const e = entries[i];
53
+ if (e.role === "user")
54
+ break; // next turn begins
55
+ if (e.role === "summary" || e.role === "rename")
56
+ continue; // not agent output
57
+ slice.push(e);
58
+ }
59
+ return slice;
60
+ }
61
+ /** Highest `seq` present in a slice (−1 if none) — the turn's content watermark. */
62
+ export function sliceContentSeq(slice) {
63
+ let max = -1;
64
+ for (const e of slice) {
65
+ if (typeof e.seq === "number" && e.seq > max)
66
+ max = e.seq;
67
+ }
68
+ return max;
69
+ }
70
+ /** True when a slice has any agent output worth summarizing. */
71
+ export function hasSummarizableContent(slice) {
72
+ return slice.some((e) => e.role === "assistant" ||
73
+ e.role === "tool" ||
74
+ e.role === "assistant_image" ||
75
+ e.role === "permission" ||
76
+ e.role === "question");
77
+ }
78
+ function truncate(s, n) {
79
+ const t = s.trim();
80
+ return t.length > n ? `${t.slice(0, n)}…` : t;
81
+ }
82
+ /** Render a turn slice into the model prompt (pure — no I/O). */
83
+ export function renderSummaryPrompt(slice) {
84
+ const lines = [];
85
+ for (const e of slice) {
86
+ switch (e.role) {
87
+ case "assistant":
88
+ lines.push(`Assistant: ${truncate(e.text, MAX_ENTRY_CHARS)}`);
89
+ break;
90
+ case "tool":
91
+ lines.push(`Tool: ${truncate(e.text, MAX_ENTRY_CHARS)}`);
92
+ break;
93
+ case "assistant_image":
94
+ lines.push("Assistant: [produced an image]");
95
+ break;
96
+ case "permission":
97
+ lines.push(`Permission ${e.resolved ? `(${e.resolved})` : "(pending)"}: ${truncate(e.text, MAX_ENTRY_CHARS)}`);
98
+ break;
99
+ case "question":
100
+ lines.push(`Question ${e.answers || e.response ? "(answered)" : "(pending)"}: ${truncate(e.text, MAX_ENTRY_CHARS)}`);
101
+ break;
102
+ case "limit":
103
+ lines.push("Notice: usage limit reached");
104
+ break;
105
+ case "system":
106
+ lines.push(`System: ${truncate(e.text, MAX_ENTRY_CHARS)}`);
107
+ break;
108
+ default:
109
+ break; // rename/summary/user never appear in a slice
110
+ }
111
+ }
112
+ let body = lines.join("\n");
113
+ if (body.length > MAX_PROMPT_CHARS)
114
+ body = `${body.slice(0, MAX_PROMPT_CHARS)}\n…`;
115
+ return INSTRUCTION + body;
116
+ }
117
+ /** Trim and strip a wrapping code fence the model may have added. */
118
+ function clean(raw) {
119
+ return raw
120
+ .trim()
121
+ .replace(/^```[a-zA-Z]*\n?/, "")
122
+ .replace(/\n?```$/, "")
123
+ .trim();
124
+ }
125
+ /**
126
+ * Produce a markdown bullet summary for one turn. Returns null on an empty
127
+ * slice, an empty result, or any error, so callers leave the turn un-condensed
128
+ * (shown in full) rather than blocking.
129
+ */
130
+ export async function generateTurnSummary(slice, sdkEnv) {
131
+ if (!hasSummarizableContent(slice))
132
+ return null;
133
+ try {
134
+ const response = query({
135
+ prompt: renderSummaryPrompt(slice),
136
+ options: {
137
+ model: SUMMARY_MODEL,
138
+ // No worktree work, no tools — this is a pure completion.
139
+ allowedTools: [],
140
+ maxTurns: 1,
141
+ ...(sdkEnv ? { env: { ...process.env, ...sdkEnv } } : {}),
142
+ },
143
+ });
144
+ let text = "";
145
+ for await (const msg of response) {
146
+ const m = msg;
147
+ if (m.type === "assistant") {
148
+ const content = m.message?.content;
149
+ if (Array.isArray(content)) {
150
+ for (const b of content) {
151
+ if (b && typeof b === "object" && b.type === "text") {
152
+ text += b.text ?? "";
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ const out = clean(text);
159
+ return out || null;
160
+ }
161
+ catch (err) {
162
+ logger.warn("model", "turn summary generation failed", {
163
+ data: { message: err instanceof Error ? err.message : String(err) },
164
+ });
165
+ return null;
166
+ }
167
+ }
168
+ //# sourceMappingURL=turn-summarizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turn-summarizer.js","sourceRoot":"","sources":["../../src/turn-summarizer.ts"],"names":[],"mappings":"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"}
@@ -61,6 +61,17 @@ export declare class WorkspaceManager {
61
61
  * `broadcastWorkspaceList` can no-op when a recompute yields identical rows.
62
62
  */
63
63
  private lastListJson;
64
+ /**
65
+ * Per-workspace debounce timer coalescing a stop's burst of events into one
66
+ * turn-summary generation (see TURN_SUMMARY_DEBOUNCE_MS).
67
+ */
68
+ private summaryTimers;
69
+ /**
70
+ * Guard against re-summarizing a turn whose content hasn't changed. Keyed by
71
+ * `${ws}:${turnStartSeq}` → the highest slice `seq` already summarized, so a
72
+ * later stop with no new entries (or the debounce firing twice) is a no-op.
73
+ */
74
+ private summarizedUpTo;
64
75
  constructor(hub: Hub,
65
76
  /** Durable per-workspace transcript, so history survives phone reboots. */
66
77
  chatlog?: ChatLog,
@@ -397,6 +408,40 @@ export declare class WorkspaceManager {
397
408
  mediaStore(): MediaStore;
398
409
  /** Ids of all currently-live workspaces (for the media orphan sweep). */
399
410
  liveWorkspaceIds(): Set<string>;
411
+ /**
412
+ * A streamed work event (assistant text, a tool call, or an image) means the
413
+ * agent is actively coding *right now*. Backends emit their own `running` only
414
+ * once at spawn and a `done` at end-of-turn, so a work chunk that arrives after
415
+ * a premature or out-of-order `done` would otherwise leave the list/project
416
+ * badge stuck at `done` while the turn is still going — the exact symptom where
417
+ * the working indicator reads "done" mid-work. The client chat spinner already
418
+ * re-arms on these events (its `busySince` timer); this mirrors that on the
419
+ * wire status every list view binds to, so all clients stay accurate from one
420
+ * fix. Only lift the "not working" states (`done`/`idle`); never clobber an
421
+ * open permission gate or a surfaced error (a real work event never precedes
422
+ * those, and stomping them would hide an actionable card).
423
+ */
424
+ private markWorking;
400
425
  private setStatus;
426
+ /**
427
+ * Debounced turn-summary trigger. A single stop can emit several events (a
428
+ * question fires both `ask_question` and `status: awaiting_permission`); this
429
+ * collapses them into one generation and lets same-tick chatlog appends land
430
+ * before we read the transcript.
431
+ */
432
+ private scheduleTurnSummary;
433
+ /**
434
+ * Generate + persist + broadcast the summary for a turn. Defaults to the latest
435
+ * turn (the one that just stopped); `turnStartSeq` targets a specific turn for
436
+ * on-demand backfill. `force` bypasses the "no new content" guard (used by the
437
+ * explicit backfill path). Best-effort: a null/failed generation is a no-op and
438
+ * leaves the turn shown in full.
439
+ */
440
+ private runTurnSummary;
441
+ /**
442
+ * On-demand backfill for a specific turn (client `turn.summarize`). Re-broadcasts
443
+ * a cached summary if one exists; otherwise generates one. Idempotent.
444
+ */
445
+ requestTurnSummary(id: string, turnStartSeq: number): Promise<void>;
401
446
  shutdown(): Promise<void>;
402
447
  }
@@ -32,6 +32,7 @@ import { maybeSyncRepo } from "./repo-sync.js";
32
32
  import { listPullRequests, prHeadRef, listIssues, createIssue } from "./github.js";
33
33
  import { composeActionPrompt } from "./actions.js";
34
34
  import { generateChatTitle } from "./titler.js";
35
+ import { buildTurnSlice, generateTurnSummary, hasSummarizableContent, lastUserSeq, sliceContentSeq, } from "./turn-summarizer.js";
35
36
  const STATE_FILE = process.env.STATE_FILE ?? join(homedir(), ".immediacy", "state.json");
36
37
  /** Root for repo-less chat workspaces' scratch dirs (one per chat). */
37
38
  const CHATS_DIR = process.env.CHATS_DIR ?? join(homedir(), ".immediacy", "chats");
@@ -74,6 +75,17 @@ function modelSwitchNotice(prevModel, nextModel, nextEffort) {
74
75
  * this only bites on a hang.
75
76
  */
76
77
  const NAME_FROM_PROMPT_TIMEOUT_MS = 6000;
78
+ /**
79
+ * Coalesce the burst of stop-like events a single stop produces (e.g. a question
80
+ * emits both `ask_question` and a `status: awaiting_permission`) into one turn
81
+ * summary, and let same-tick chatlog appends land first. Short — this only debounces.
82
+ */
83
+ const TURN_SUMMARY_DEBOUNCE_MS = 250;
84
+ /**
85
+ * Hard deadline for the turn-summary model call. Fire-and-forget (it never blocks
86
+ * the turn), but bounded so a hung API can't leave a generation dangling.
87
+ */
88
+ const TURN_SUMMARY_TIMEOUT_MS = 20000;
77
89
  export class WorkspaceManager {
78
90
  hub;
79
91
  chatlog;
@@ -90,6 +102,17 @@ export class WorkspaceManager {
90
102
  * `broadcastWorkspaceList` can no-op when a recompute yields identical rows.
91
103
  */
92
104
  lastListJson = "";
105
+ /**
106
+ * Per-workspace debounce timer coalescing a stop's burst of events into one
107
+ * turn-summary generation (see TURN_SUMMARY_DEBOUNCE_MS).
108
+ */
109
+ summaryTimers = new Map();
110
+ /**
111
+ * Guard against re-summarizing a turn whose content hasn't changed. Keyed by
112
+ * `${ws}:${turnStartSeq}` → the highest slice `seq` already summarized, so a
113
+ * later stop with no new entries (or the debounce firing twice) is a no-op.
114
+ */
115
+ summarizedUpTo = new Map();
93
116
  constructor(hub,
94
117
  /** Durable per-workspace transcript, so history survives phone reboots. */
95
118
  chatlog = new ChatLog(),
@@ -1235,14 +1258,17 @@ export class WorkspaceManager {
1235
1258
  const id = live.record.id;
1236
1259
  switch (e.kind) {
1237
1260
  case "assistant":
1261
+ this.markWorking(live);
1238
1262
  this.hub.broadcast("event.assistant_message", id, { ws: id, text: e.text });
1239
1263
  void this.chatlog.append(id, { ts: Date.now(), role: "assistant", text: e.text });
1240
1264
  break;
1241
1265
  case "image":
1242
1266
  // Tool-result / assistant-content image — persist + broadcast metadata.
1267
+ this.markWorking(live);
1243
1268
  void this.surfaceImage(live, e);
1244
1269
  break;
1245
1270
  case "tool_use":
1271
+ this.markWorking(live);
1246
1272
  this.hub.broadcast("event.tool_use", id, {
1247
1273
  ws: id,
1248
1274
  tool: e.tool,
@@ -1301,6 +1327,8 @@ export class WorkspaceManager {
1301
1327
  requestId: e.requestId,
1302
1328
  questions: e.questions,
1303
1329
  });
1330
+ // The agent stopped to ask a question — a turn stop; summarize it.
1331
+ this.scheduleTurnSummary(live);
1304
1332
  break;
1305
1333
  case "session":
1306
1334
  // Attribute the id to the backend that minted it, so a later switch back
@@ -1317,6 +1345,10 @@ export class WorkspaceManager {
1317
1345
  break;
1318
1346
  case "status":
1319
1347
  this.setStatus(live, e.status);
1348
+ // The agent paused for a permission — a turn stop. Summarize it for the
1349
+ // condensed view (debounced; the paired `permission` event lands too).
1350
+ if (e.status === "awaiting_permission")
1351
+ this.scheduleTurnSummary(live);
1320
1352
  break;
1321
1353
  case "done": {
1322
1354
  // Surface any screenshots the agent wrote to disk this turn (the
@@ -1356,6 +1388,8 @@ export class WorkspaceManager {
1356
1388
  ws: id,
1357
1389
  context: e.summary ? { summary: e.summary } : undefined,
1358
1390
  });
1391
+ // The turn is done — condense it for the condensed chat view.
1392
+ this.scheduleTurnSummary(live);
1359
1393
  break;
1360
1394
  }
1361
1395
  case "limit":
@@ -1502,6 +1536,24 @@ export class WorkspaceManager {
1502
1536
  liveWorkspaceIds() {
1503
1537
  return new Set(this.workspaces.keys());
1504
1538
  }
1539
+ /**
1540
+ * A streamed work event (assistant text, a tool call, or an image) means the
1541
+ * agent is actively coding *right now*. Backends emit their own `running` only
1542
+ * once at spawn and a `done` at end-of-turn, so a work chunk that arrives after
1543
+ * a premature or out-of-order `done` would otherwise leave the list/project
1544
+ * badge stuck at `done` while the turn is still going — the exact symptom where
1545
+ * the working indicator reads "done" mid-work. The client chat spinner already
1546
+ * re-arms on these events (its `busySince` timer); this mirrors that on the
1547
+ * wire status every list view binds to, so all clients stay accurate from one
1548
+ * fix. Only lift the "not working" states (`done`/`idle`); never clobber an
1549
+ * open permission gate or a surfaced error (a real work event never precedes
1550
+ * those, and stomping them would hide an actionable card).
1551
+ */
1552
+ markWorking(live) {
1553
+ const s = live.record.status;
1554
+ if (s === "done" || s === "idle")
1555
+ this.setStatus(live, "running");
1556
+ }
1505
1557
  setStatus(live, status) {
1506
1558
  live.record.status = status;
1507
1559
  const id = live.record.id;
@@ -1512,6 +1564,95 @@ export class WorkspaceManager {
1512
1564
  status,
1513
1565
  });
1514
1566
  }
1567
+ /**
1568
+ * Debounced turn-summary trigger. A single stop can emit several events (a
1569
+ * question fires both `ask_question` and `status: awaiting_permission`); this
1570
+ * collapses them into one generation and lets same-tick chatlog appends land
1571
+ * before we read the transcript.
1572
+ */
1573
+ scheduleTurnSummary(live) {
1574
+ const id = live.record.id;
1575
+ const existing = this.summaryTimers.get(id);
1576
+ if (existing)
1577
+ clearTimeout(existing);
1578
+ const t = setTimeout(() => {
1579
+ this.summaryTimers.delete(id);
1580
+ void this.runTurnSummary(id);
1581
+ }, TURN_SUMMARY_DEBOUNCE_MS);
1582
+ // Don't keep the event loop alive just for a best-effort summary.
1583
+ t.unref?.();
1584
+ this.summaryTimers.set(id, t);
1585
+ }
1586
+ /**
1587
+ * Generate + persist + broadcast the summary for a turn. Defaults to the latest
1588
+ * turn (the one that just stopped); `turnStartSeq` targets a specific turn for
1589
+ * on-demand backfill. `force` bypasses the "no new content" guard (used by the
1590
+ * explicit backfill path). Best-effort: a null/failed generation is a no-op and
1591
+ * leaves the turn shown in full.
1592
+ */
1593
+ async runTurnSummary(id, opts = {}) {
1594
+ const entries = this.chatlog.entries(id);
1595
+ const turnStartSeq = opts.turnStartSeq ?? lastUserSeq(entries);
1596
+ if (turnStartSeq === null || turnStartSeq === undefined)
1597
+ return;
1598
+ const slice = buildTurnSlice(entries, turnStartSeq);
1599
+ if (!hasSummarizableContent(slice))
1600
+ return;
1601
+ const key = `${id}:${turnStartSeq}`;
1602
+ const contentSeq = sliceContentSeq(slice);
1603
+ // Skip if this turn was already summarized at the same content watermark
1604
+ // (the debounce firing twice, or a later stop that added nothing new).
1605
+ if (!opts.force && this.summarizedUpTo.get(key) === contentSeq)
1606
+ return;
1607
+ // Claim the watermark before the await so a concurrent trigger doesn't
1608
+ // double-generate; release it on failure so a retry can happen later.
1609
+ const prevWatermark = this.summarizedUpTo.get(key);
1610
+ this.summarizedUpTo.set(key, contentSeq);
1611
+ const sdkEnv = claudeSdkEnv(this.appConfig.get(), this.credentials.getKey("anthropic"));
1612
+ const timeout = new Promise((resolve) => {
1613
+ const t = setTimeout(() => resolve(null), TURN_SUMMARY_TIMEOUT_MS);
1614
+ t.unref?.();
1615
+ });
1616
+ const text = await Promise.race([generateTurnSummary(slice, sdkEnv), timeout]);
1617
+ if (!text) {
1618
+ // Generation failed/timed out — un-claim so a future stop retries.
1619
+ if (this.summarizedUpTo.get(key) === contentSeq) {
1620
+ if (prevWatermark === undefined)
1621
+ this.summarizedUpTo.delete(key);
1622
+ else
1623
+ this.summarizedUpTo.set(key, prevWatermark);
1624
+ }
1625
+ return;
1626
+ }
1627
+ // Append first so the entry gets its `seq`, then broadcast with it.
1628
+ const entry = { ts: Date.now(), role: "summary", text, turnStartSeq };
1629
+ await this.chatlog.append(id, entry);
1630
+ this.hub.broadcast("event.turn_summary", id, {
1631
+ ws: id,
1632
+ turnStartSeq,
1633
+ text,
1634
+ seq: entry.seq,
1635
+ });
1636
+ }
1637
+ /**
1638
+ * On-demand backfill for a specific turn (client `turn.summarize`). Re-broadcasts
1639
+ * a cached summary if one exists; otherwise generates one. Idempotent.
1640
+ */
1641
+ async requestTurnSummary(id, turnStartSeq) {
1642
+ const existing = this.chatlog
1643
+ .entries(id)
1644
+ .find((e) => e.role === "summary" && e.turnStartSeq === turnStartSeq);
1645
+ if (existing) {
1646
+ this.hub.broadcast("event.turn_summary", id, {
1647
+ ws: id,
1648
+ turnStartSeq,
1649
+ text: existing.text,
1650
+ seq: existing.seq,
1651
+ });
1652
+ return;
1653
+ }
1654
+ await this.runTurnSummary(id, { turnStartSeq, force: true });
1655
+ }
1515
1656
  async shutdown() {
1516
1657
  for (const live of this.workspaces.values()) {
1517
1658
  await live.agent?.stop();