@agentclientprotocol/claude-agent-acp 0.88.1-preview.6 → 0.88.1-preview.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.
Files changed (60) hide show
  1. package/dist/acp-agent.d.ts +26 -1
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +91 -17
  4. package/dist/air-extension.d.ts +23 -0
  5. package/dist/air-extension.d.ts.map +1 -1
  6. package/dist/air-extension.js +23 -0
  7. package/dist/diff.d.ts +2 -0
  8. package/dist/diff.d.ts.map +1 -1
  9. package/dist/diff.js +37 -1
  10. package/dist/mcp-command.d.ts.map +1 -1
  11. package/dist/mcp-command.js +5 -0
  12. package/dist/session-index/activity.d.ts +56 -0
  13. package/dist/session-index/activity.d.ts.map +1 -0
  14. package/dist/session-index/activity.js +96 -0
  15. package/dist/session-index/archive-title.d.ts +52 -0
  16. package/dist/session-index/archive-title.d.ts.map +1 -0
  17. package/dist/session-index/archive-title.js +77 -0
  18. package/dist/session-index/connection.d.ts +152 -0
  19. package/dist/session-index/connection.d.ts.map +1 -0
  20. package/dist/session-index/connection.js +310 -0
  21. package/dist/session-index/dir-listing.d.ts +30 -0
  22. package/dist/session-index/dir-listing.d.ts.map +1 -0
  23. package/dist/session-index/dir-listing.js +110 -0
  24. package/dist/session-index/explicit-title.d.ts +34 -0
  25. package/dist/session-index/explicit-title.d.ts.map +1 -0
  26. package/dist/session-index/explicit-title.js +65 -0
  27. package/dist/session-index/first-prompt.d.ts +37 -0
  28. package/dist/session-index/first-prompt.d.ts.map +1 -0
  29. package/dist/session-index/first-prompt.js +195 -0
  30. package/dist/session-index/list-subscriptions.d.ts +190 -0
  31. package/dist/session-index/list-subscriptions.d.ts.map +1 -0
  32. package/dist/session-index/list-subscriptions.js +1163 -0
  33. package/dist/session-index/live-registry.d.ts +72 -0
  34. package/dist/session-index/live-registry.d.ts.map +1 -0
  35. package/dist/session-index/live-registry.js +240 -0
  36. package/dist/session-index/project-dirs.d.ts +45 -0
  37. package/dist/session-index/project-dirs.d.ts.map +1 -0
  38. package/dist/session-index/project-dirs.js +122 -0
  39. package/dist/session-index/service.d.ts +158 -0
  40. package/dist/session-index/service.d.ts.map +1 -0
  41. package/dist/session-index/service.js +546 -0
  42. package/dist/session-index/session-index.d.ts +201 -0
  43. package/dist/session-index/session-index.d.ts.map +1 -0
  44. package/dist/session-index/session-index.js +740 -0
  45. package/dist/session-index/session-info.d.ts +16 -0
  46. package/dist/session-index/session-info.d.ts.map +1 -0
  47. package/dist/session-index/session-info.js +51 -0
  48. package/dist/session-index/transcript-scan.d.ts +137 -0
  49. package/dist/session-index/transcript-scan.d.ts.map +1 -0
  50. package/dist/session-index/transcript-scan.js +545 -0
  51. package/dist/session-index/worktrees.d.ts +25 -0
  52. package/dist/session-index/worktrees.d.ts.map +1 -0
  53. package/dist/session-index/worktrees.js +128 -0
  54. package/dist/session-titles.d.ts +21 -0
  55. package/dist/session-titles.d.ts.map +1 -1
  56. package/dist/session-titles.js +101 -5
  57. package/dist/utils.d.ts +31 -0
  58. package/dist/utils.d.ts.map +1 -1
  59. package/dist/utils.js +60 -2
  60. package/package.json +1 -1
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The git worktrees of a working directory, read from the repository files.
3
+ *
4
+ * The SDK `listSessions({ dir })` includes the sessions of every worktree of
5
+ * the repository and runs `git worktree list` for that on each call. This
6
+ * module reads the same list without spawning git, the way the Codex TUI does:
7
+ * the main worktree is the parent of the common `.git` directory, and each
8
+ * linked worktree is named by `<common-dir>/worktrees/<name>/gitdir`.
9
+ * A worktree whose directory no longer exists is dropped. Nothing is cached:
10
+ * `git worktree move` rewrites a `gitdir` file without touching the
11
+ * `worktrees` directory, so a cache keyed on it would miss the move.
12
+ */
13
+ import * as fs from "node:fs/promises";
14
+ import * as path from "node:path";
15
+ import { normalizePath } from "./project-dirs.js";
16
+ async function statOrUndefined(target) {
17
+ try {
18
+ return await fs.stat(target);
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ }
24
+ /** The git dir of the repository that contains `cwd`, or undefined. */
25
+ async function findGitDir(cwd) {
26
+ let current = path.resolve(cwd);
27
+ for (;;) {
28
+ const candidate = path.join(current, ".git");
29
+ const stats = await statOrUndefined(candidate);
30
+ if (stats?.isDirectory())
31
+ return candidate;
32
+ if (stats?.isFile()) {
33
+ try {
34
+ const text = await fs.readFile(candidate, "utf8");
35
+ const match = /^gitdir:\s*(.+)$/m.exec(text);
36
+ if (match)
37
+ return path.resolve(current, match[1].trim());
38
+ }
39
+ catch {
40
+ // An unreadable `.git` file is not a repository.
41
+ }
42
+ return undefined;
43
+ }
44
+ const parent = path.dirname(current);
45
+ if (parent === current)
46
+ return undefined;
47
+ current = parent;
48
+ }
49
+ }
50
+ async function commonDirOf(gitDir) {
51
+ try {
52
+ const text = await fs.readFile(path.join(gitDir, "commondir"), "utf8");
53
+ const value = text.trim();
54
+ if (value)
55
+ return path.resolve(gitDir, value);
56
+ }
57
+ catch {
58
+ // No `commondir`: the git dir is the common dir.
59
+ }
60
+ return gitDir;
61
+ }
62
+ async function linkedWorktrees(commonDir) {
63
+ const worktreesDir = path.join(commonDir, "worktrees");
64
+ const stats = await statOrUndefined(worktreesDir);
65
+ if (!stats?.isDirectory())
66
+ return [];
67
+ let names;
68
+ try {
69
+ names = await fs.readdir(worktreesDir);
70
+ }
71
+ catch {
72
+ return [];
73
+ }
74
+ return (await Promise.all(names.map(async (name) => {
75
+ try {
76
+ const text = await fs.readFile(path.join(worktreesDir, name, "gitdir"), "utf8");
77
+ const gitFile = text.trim();
78
+ return gitFile ? path.dirname(path.resolve(worktreesDir, name, gitFile)) : undefined;
79
+ }
80
+ catch {
81
+ return undefined;
82
+ }
83
+ }))).filter((value) => value !== undefined);
84
+ }
85
+ /** The root of the worktree that contains `cwd`: the directory of its
86
+ * `.git` (a directory in the main worktree, a file in a linked one). */
87
+ async function worktreeRootOf(cwd) {
88
+ let current = path.resolve(cwd);
89
+ for (;;) {
90
+ if (await statOrUndefined(path.join(current, ".git")))
91
+ return current;
92
+ const parent = path.dirname(current);
93
+ if (parent === current)
94
+ return undefined;
95
+ current = parent;
96
+ }
97
+ }
98
+ /**
99
+ * The same subdirectory as `cwd` in every existing worktree of its
100
+ * repository (the main one and the linked ones), as the Codex TUI expands a
101
+ * cwd: for `/repo/packages/a`, `/wt1/packages/a` if it exists. For a cwd at a
102
+ * worktree root, the worktree roots. Empty outside a repository.
103
+ */
104
+ export async function worktreeCounterparts(cwd) {
105
+ const root = await worktreeRootOf(cwd);
106
+ if (!root)
107
+ return [];
108
+ const relative = path.relative(root, path.resolve(cwd));
109
+ const candidates = (await repositoryWorktrees(cwd)).map((worktree) => relative ? path.join(worktree, relative) : worktree);
110
+ const existing = await Promise.all(candidates.map(async (candidate) => (await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined));
111
+ return [...new Set(existing.filter((value) => value !== undefined))];
112
+ }
113
+ /**
114
+ * Every existing worktree of the repository that contains `cwd`: the main
115
+ * worktree and the linked ones. Empty outside a repository.
116
+ */
117
+ export async function repositoryWorktrees(cwd) {
118
+ const gitDir = await findGitDir(cwd);
119
+ if (!gitDir)
120
+ return [];
121
+ const commonDir = await commonDirOf(gitDir);
122
+ const candidates = [];
123
+ if (path.basename(commonDir) === ".git")
124
+ candidates.push(path.dirname(commonDir));
125
+ candidates.push(...(await linkedWorktrees(commonDir)));
126
+ const existing = await Promise.all(candidates.map(async (candidate) => (await statOrUndefined(candidate))?.isDirectory() ? normalizePath(candidate) : undefined));
127
+ return [...new Set(existing.filter((value) => value !== undefined))];
128
+ }
@@ -36,6 +36,18 @@ export declare class SessionTitles {
36
36
  * the same field as a user `/rename`, so re-titling could silently overwrite
37
37
  * one. Released by {@link reset}, and when generation yields nothing. */
38
38
  private settled;
39
+ /** The title a client set, which no generated title replaces. */
40
+ private readonly explicit;
41
+ /** The title last stored through {@link setExplicitTitle}, as stored
42
+ * (with the archive prefix of an archived session): the title the CLI
43
+ * holds. */
44
+ private persistedTitle?;
45
+ /** Client title changes in flight, and the title state before the first. */
46
+ private explicitChanges;
47
+ private beforeExplicit?;
48
+ get storedTitle(): string | undefined;
49
+ /** The title last published, as the client shows it. */
50
+ get shownTitle(): string | undefined;
39
51
  constructor(agent: ClaudeAcpAgent, sessionId: string);
40
52
  /** Collect a prompt's own text for the title, skipping openers not worth
41
53
  * titling after. No-op once the title is settled. */
@@ -58,6 +70,15 @@ export declare class SessionTitles {
58
70
  * not possible do we fall back to `summary`, which for an SDK-driven session
59
71
  * is just the raw first prompt. */
60
72
  onTurnEnd(session: Session): Promise<void>;
73
+ /** Applies a title that the client chose (a rename, an archive or an
74
+ * unarchive). Settles the title for good, waits for a generation in flight
75
+ * so that its persisted title cannot land after this one, persists the
76
+ * title with `persist`, which returns what it stored, if anything, and
77
+ * publishes `title` unless it is undefined. A change that stored and
78
+ * publishes nothing leaves the title state as it was. */
79
+ setExplicitTitle(publish: string | ((stored: string | undefined) => string) | undefined, persist: () => Promise<string | undefined | void>): Promise<void>;
80
+ /** Records a title the CLI took, as soon as it took it. */
81
+ rememberStoredTitle(title: string): void;
61
82
  /** Read the SDK's stored info for this session. A missing session file or read
62
83
  * error is non-fatal: the title is best-effort and another turn will retry. */
63
84
  private readSessionInfo;
@@ -1 +1 @@
1
- {"version":3,"file":"session-titles.d.ts","sourceRoot":"","sources":["../src/session-titles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE5E,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AA0B9D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAUlD;AAED;gEACgE;AAChE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAGrF;AAOD;;iDAEiD;AACjD,qBAAa,aAAa;IAgBtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAhB5B;sEACkE;IAClE,OAAO,CAAC,SAAS,CAAC,CAAS;IAE3B;yEACqE;IACrE,OAAO,CAAC,OAAO,CAAC,CAAS;IAEzB;;;8EAG0E;IAC1E,OAAO,CAAC,OAAO,CAAS;gBAGL,KAAK,EAAE,cAAc,EACrB,SAAS,EAAE,MAAM;IAGpC;0DACsD;IACtD,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,GAAG,IAAI;IAW/C;kFAC8E;IAC9E,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI;IAO5C;iEAC6D;IAC7D,KAAK,IAAI,IAAI;IAMb;;;;;;;;;;wCAUoC;IAC9B,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IA+BhD;oFACgF;YAClE,eAAe;IAS7B,uEAAuE;YACzD,OAAO;IAgBrB;iFAC6E;IAC7E,OAAO,CAAC,UAAU;IAUlB;;;;;kBAKc;YACA,oBAAoB;CAoCnC"}
1
+ {"version":3,"file":"session-titles.d.ts","sourceRoot":"","sources":["../src/session-titles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE5E,OAAO,KAAK,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AA+B9D,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAqBlD;AAED;gEACgE;AAChE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAGrF;AAOD;;iDAEiD;AACjD,qBAAa,aAAa;IAqCtB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,SAAS;IArC5B;sEACkE;IAClE,OAAO,CAAC,SAAS,CAAC,CAAS;IAE3B;yEACqE;IACrE,OAAO,CAAC,OAAO,CAAC,CAAS;IAEzB;;;8EAG0E;IAC1E,OAAO,CAAC,OAAO,CAAS;IAExB,iEAAiE;IACjE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAuB;IAEhD;;iBAEa;IACb,OAAO,CAAC,cAAc,CAAC,CAAS;IAEhC,4EAA4E;IAC5E,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,cAAc,CAAC,CAAoD;IAE3E,IAAI,WAAW,IAAI,MAAM,GAAG,SAAS,CAEpC;IAED,wDAAwD;IACxD,IAAI,UAAU,IAAI,MAAM,GAAG,SAAS,CAEnC;gBAGkB,KAAK,EAAE,cAAc,EACrB,SAAS,EAAE,MAAM;IAGpC;0DACsD;IACtD,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,GAAG,IAAI;IAW/C;kFAC8E;IAC9E,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,IAAI;IAO5C;iEAC6D;IAC7D,KAAK,IAAI,IAAI;IAQb;;;;;;;;;;wCAUoC;IAC9B,SAAS,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAoDhD;;;;;8DAK0D;IACpD,gBAAgB,CACpB,OAAO,EAAE,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,MAAM,CAAC,GAAG,SAAS,EACtE,OAAO,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC,GAChD,OAAO,CAAC,IAAI,CAAC;IAkChB,2DAA2D;IAC3D,mBAAmB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAIxC;oFACgF;YAClE,eAAe;IAS7B,uEAAuE;YACzD,OAAO;IAgBrB;iFAC6E;IAC7E,OAAO,CAAC,UAAU;IAUlB;;;;;kBAKc;YACA,oBAAoB;CAoCnC"}
@@ -14,6 +14,7 @@
14
14
  * {@link SessionTitles.reset} on `conversation_reset`.
15
15
  */
16
16
  import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
17
+ import { ExplicitTitle } from "./session-index/explicit-title.js";
17
18
  const MAX_TITLE_LENGTH = 256;
18
19
  /** How much session text the title is generated from. The CLI caps its own
19
20
  * title input at the same 1000 trailing characters. */
@@ -21,7 +22,21 @@ const MAX_TITLE_CONTEXT_LENGTH = 1000;
21
22
  /** Below this the generator returns null without calling the model, so there is
22
23
  * nothing to gain by asking yet. */
23
24
  const MIN_TITLE_CONTEXT_LENGTH = 10;
25
+ /** How much of a long text {@link sanitizeTitle} reads first. Collapsed, it
26
+ * is longer than a title unless it is mostly whitespace. */
27
+ const TITLE_SCAN_LENGTH = 4096;
24
28
  export function sanitizeTitle(text) {
29
+ if (text.length > TITLE_SCAN_LENGTH) {
30
+ // A stored summary can be a whole first prompt of megabytes. The title
31
+ // depends only on the start of the text: collapsing the whitespace of a
32
+ // prefix gives a prefix of the collapsed text. Two more characters than a
33
+ // title hold at least one that is not a space (spaces are collapsed), so
34
+ // the full text is longer than a title and gets the same cut.
35
+ const head = text.slice(0, TITLE_SCAN_LENGTH).replace(/\s+/g, " ").trimStart();
36
+ if (head.length >= MAX_TITLE_LENGTH + 2) {
37
+ return head.slice(0, MAX_TITLE_LENGTH - 1) + "…";
38
+ }
39
+ }
25
40
  // Replace newlines and collapse whitespace
26
41
  const sanitized = text
27
42
  .replace(/[\r\n]+/g, " ")
@@ -59,6 +74,22 @@ export class SessionTitles {
59
74
  * the same field as a user `/rename`, so re-titling could silently overwrite
60
75
  * one. Released by {@link reset}, and when generation yields nothing. */
61
76
  settled = false;
77
+ /** The title a client set, which no generated title replaces. */
78
+ explicit = new ExplicitTitle();
79
+ /** The title last stored through {@link setExplicitTitle}, as stored
80
+ * (with the archive prefix of an archived session): the title the CLI
81
+ * holds. */
82
+ persistedTitle;
83
+ /** Client title changes in flight, and the title state before the first. */
84
+ explicitChanges = 0;
85
+ beforeExplicit;
86
+ get storedTitle() {
87
+ return this.persistedTitle;
88
+ }
89
+ /** The title last published, as the client shows it. */
90
+ get shownTitle() {
91
+ return this.lastTitle;
92
+ }
62
93
  constructor(agent, sessionId) {
63
94
  this.agent = agent;
64
95
  this.sessionId = sessionId;
@@ -86,8 +117,10 @@ export class SessionTitles {
86
117
  * `conversation_reset`, which mounts a fresh transcript. */
87
118
  reset() {
88
119
  this.settled = false;
120
+ this.explicit.reset();
89
121
  this.context = undefined;
90
122
  this.lastTitle = undefined;
123
+ this.persistedTitle = undefined;
91
124
  }
92
125
  /** Turn-end title handling. `idle` is the SDK's turn-over signal, so it is
93
126
  * when a title may have landed or become generatable.
@@ -101,7 +134,26 @@ export class SessionTitles {
101
134
  * not possible do we fall back to `summary`, which for an SDK-driven session
102
135
  * is just the raw first prompt. */
103
136
  async onTurnEnd(session) {
137
+ if (this.explicit.settledByClient) {
138
+ // No title is generated over the one the client set, but a custom title
139
+ // stored since by someone else (a `/rename`) is adopted, as before.
140
+ const renames = this.explicit.renames;
141
+ const info = await this.readSessionInfo(session);
142
+ // No client change ran during the read, so the stored title is current;
143
+ // an unchanged one is not published again.
144
+ const stored = info?.customTitle;
145
+ if (stored && this.explicit.settledByClient && this.explicit.renames === renames) {
146
+ await this.publish(stored, info.lastModified);
147
+ }
148
+ return;
149
+ }
150
+ // While a client title change is in flight, the session info read below
151
+ // could still hold the previous title.
152
+ if (this.explicit.active)
153
+ return;
104
154
  const info = await this.readSessionInfo(session);
155
+ if (this.explicit.active)
156
+ return;
105
157
  if (info?.customTitle) {
106
158
  this.settled = true;
107
159
  this.context = undefined;
@@ -113,9 +165,9 @@ export class SessionTitles {
113
165
  : undefined;
114
166
  if (this.canRequest(session)) {
115
167
  this.settled = true;
116
- void this.requestGenerateTitle(session, fallback).catch((error) => {
168
+ this.explicit.track(this.requestGenerateTitle(session, fallback).catch((error) => {
117
169
  this.agent.logger.error(`Session ${this.sessionId}: session title update failed: ${error}`);
118
- });
170
+ }));
119
171
  return;
120
172
  }
121
173
  // Only while this session has no title of its own: `summary` degrades to the
@@ -125,6 +177,50 @@ export class SessionTitles {
125
177
  await this.publish(fallback.title, fallback.lastModified);
126
178
  }
127
179
  }
180
+ /** Applies a title that the client chose (a rename, an archive or an
181
+ * unarchive). Settles the title for good, waits for a generation in flight
182
+ * so that its persisted title cannot land after this one, persists the
183
+ * title with `persist`, which returns what it stored, if anything, and
184
+ * publishes `title` unless it is undefined. A change that stored and
185
+ * publishes nothing leaves the title state as it was. */
186
+ async setExplicitTitle(publish, persist) {
187
+ // The state before the first of overlapping changes, which a change that
188
+ // fails or names nothing puts back.
189
+ if (this.explicitChanges++ === 0) {
190
+ this.beforeExplicit = { settled: this.settled, context: this.context };
191
+ }
192
+ const previous = this.beforeExplicit;
193
+ this.settled = true;
194
+ this.context = undefined;
195
+ let stored;
196
+ try {
197
+ await this.explicit.apply(async () => {
198
+ stored = (await persist()) ?? undefined;
199
+ if (stored !== undefined)
200
+ this.persistedTitle = stored;
201
+ return stored !== undefined || publish !== undefined;
202
+ }, () => {
203
+ this.settled = previous.settled;
204
+ this.context ??= previous.context;
205
+ });
206
+ }
207
+ finally {
208
+ if (--this.explicitChanges === 0)
209
+ this.beforeExplicit = undefined;
210
+ }
211
+ const title = typeof publish === "function" ? publish(stored) : publish;
212
+ if (title === undefined)
213
+ return;
214
+ this.lastTitle = title;
215
+ await this.agent.client.sessionUpdate({
216
+ sessionId: this.sessionId,
217
+ update: { sessionUpdate: "session_info_update", title },
218
+ });
219
+ }
220
+ /** Records a title the CLI took, as soon as it took it. */
221
+ rememberStoredTitle(title) {
222
+ this.persistedTitle = title;
223
+ }
128
224
  /** Read the SDK's stored info for this session. A missing session file or read
129
225
  * error is non-fatal: the title is best-effort and another turn will retry. */
130
226
  async readSessionInfo(session) {
@@ -138,7 +234,7 @@ export class SessionTitles {
138
234
  }
139
235
  /** Notify the client of a title, unless it is the one we last sent. */
140
236
  async publish(rawTitle, lastModified) {
141
- const title = sanitizeTitle(rawTitle);
237
+ const title = sanitizeTitle(this.agent.clientTitle?.(rawTitle) ?? rawTitle);
142
238
  if (title === this.lastTitle) {
143
239
  return;
144
240
  }
@@ -182,8 +278,8 @@ export class SessionTitles {
182
278
  this.agent.logger.error(`Session ${this.sessionId}: failed to generate a session title: ${error}`);
183
279
  }
184
280
  // A session torn down or replaced while the title was in flight must not
185
- // adopt it.
186
- if (this.agent.sessions[this.sessionId] !== session) {
281
+ // adopt it, and a title the client set meanwhile wins.
282
+ if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
187
283
  return;
188
284
  }
189
285
  if (!title) {
package/dist/utils.d.ts CHANGED
@@ -12,6 +12,37 @@ export declare class Pushable<T> implements AsyncIterable<T> {
12
12
  export declare function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array>;
13
13
  export declare function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array>;
14
14
  export declare function unreachable(value: never, logger?: Logger): void;
15
+ /**
16
+ * Yields to the event loop when the current macrotask has run for too long.
17
+ *
18
+ * An await of an already-resolved promise continues in a microtask, so a loop
19
+ * that drains a backlog of buffered messages never lets timers or I/O run:
20
+ * incoming requests, `session/cancel` among them, wait until the backlog is
21
+ * gone. A loop calls {@link maybeYield} once per item. Within the budget it
22
+ * returns undefined and the loop goes on at once. Past the budget it returns a
23
+ * promise that resolves in the check phase of the event loop, after pending
24
+ * I/O has run.
25
+ *
26
+ * The budget is measured from the first call since the event loop last
27
+ * reached its check phase, so a loop that already waits on real I/O never
28
+ * yields, and all loops that share one instance share one budget per
29
+ * event-loop iteration.
30
+ *
31
+ * A test that fakes `setImmediate` and drives such a loop for longer than the
32
+ * budget must advance the fake timers, or the loop waits for them.
33
+ */
34
+ export declare class EventLoopYielder {
35
+ private readonly budgetMs;
36
+ private readonly now;
37
+ /** Whether a check-phase marker is pending. While it is, the event loop has
38
+ * not reached its check phase since {@link sliceStart}. */
39
+ private markerPending;
40
+ private sliceStart;
41
+ constructor(budgetMs: number, now?: () => number);
42
+ maybeYield(): Promise<void> | undefined;
43
+ }
44
+ /** The yielder shared by the loops that forward messages to the client. */
45
+ export declare const messageLoopYielder: EventLoopYielder;
15
46
  export declare function sleep(time: number): Promise<void>;
16
47
  /** The outcome of {@link raceTimeoutAndAbort}. */
17
48
  export type RaceOutcome<T> = {
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAGxC,qBAAa,QAAQ,CAAC,CAAC,CAAE,YAAW,aAAa,CAAC,CAAC,CAAC;IAClD,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,SAAS,CAA8C;IAC/D,OAAO,CAAC,IAAI,CAAS;IAErB,IAAI,CAAC,IAAI,EAAE,CAAC;IASZ,GAAG;IAQH,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC;CAgB3C;AAGD,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,QAAQ,GAAG,cAAc,CAAC,UAAU,CAAC,CAclF;AAED,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,QAAQ,GAAG,cAAc,CAAC,UAAU,CAAC,CAUlF;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,GAAE,MAAgB,QAQjE;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEjD;AAED,kDAAkD;AAClD,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEpG;kFACkF;AAClF,wBAAsB,mBAAmB,CAAC,CAAC,EACzC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAoBzB"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAC;AAGxC,qBAAa,QAAQ,CAAC,CAAC,CAAE,YAAW,aAAa,CAAC,CAAC,CAAC;IAClD,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,SAAS,CAA8C;IAC/D,OAAO,CAAC,IAAI,CAAS;IAErB,IAAI,CAAC,IAAI,EAAE,CAAC;IASZ,GAAG;IAQH,CAAC,MAAM,CAAC,aAAa,CAAC,IAAI,aAAa,CAAC,CAAC,CAAC;CAgB3C;AAKD,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,QAAQ,GAAG,cAAc,CAAC,UAAU,CAAC,CAoBlF;AAED,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,QAAQ,GAAG,cAAc,CAAC,UAAU,CAAC,CAUlF;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,GAAE,MAAgB,QAQjE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,gBAAgB;IAOzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,GAAG;IAPtB;gEAC4D;IAC5D,OAAO,CAAC,aAAa,CAAS;IAC9B,OAAO,CAAC,UAAU,CAAK;gBAGJ,QAAQ,EAAE,MAAM,EAChB,GAAG,GAAE,MAAM,MAAgC;IAG9D,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,SAAS;CAaxC;AAMD,2EAA2E;AAC3E,eAAO,MAAM,kBAAkB,kBAA+C,CAAC;AAE/E,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEjD;AAED,kDAAkD;AAClD,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,CAAC;AAEpG;kFACkF;AAClF,wBAAsB,mBAAmB,CAAC,CAAC,EACzC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,WAAW,GAClB,OAAO,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,CAoBzB"}
package/dist/utils.js CHANGED
@@ -38,12 +38,20 @@ export class Pushable {
38
38
  };
39
39
  }
40
40
  }
41
- // Helper to convert Node.js streams to Web Streams
41
+ // Helper to convert Node.js streams to Web Streams. A byte stream gets each
42
+ // chunk without a copy, so the caller must not change a chunk until its write
43
+ // resolves.
42
44
  export function nodeToWebWritable(nodeStream) {
43
45
  return new WritableStream({
44
46
  write(chunk) {
45
47
  return new Promise((resolve, reject) => {
46
- nodeStream.write(Buffer.from(chunk), (err) => {
48
+ // A Uint8Array is written as it is: Node wraps it in a Buffer view
49
+ // without a copy. The ACP encoder hands each message a fresh array,
50
+ // so nothing changes the bytes while the write is pending. An
51
+ // object-mode stream would get the array itself, so it keeps
52
+ // getting a Buffer copy, as before.
53
+ const data = nodeStream.writableObjectMode ? Buffer.from(chunk) : chunk;
54
+ nodeStream.write(data, (err) => {
47
55
  if (err) {
48
56
  reject(err);
49
57
  }
@@ -76,6 +84,56 @@ export function unreachable(value, logger = console) {
76
84
  }
77
85
  logger.error(`Unexpected case: ${valueAsString}`);
78
86
  }
87
+ /**
88
+ * Yields to the event loop when the current macrotask has run for too long.
89
+ *
90
+ * An await of an already-resolved promise continues in a microtask, so a loop
91
+ * that drains a backlog of buffered messages never lets timers or I/O run:
92
+ * incoming requests, `session/cancel` among them, wait until the backlog is
93
+ * gone. A loop calls {@link maybeYield} once per item. Within the budget it
94
+ * returns undefined and the loop goes on at once. Past the budget it returns a
95
+ * promise that resolves in the check phase of the event loop, after pending
96
+ * I/O has run.
97
+ *
98
+ * The budget is measured from the first call since the event loop last
99
+ * reached its check phase, so a loop that already waits on real I/O never
100
+ * yields, and all loops that share one instance share one budget per
101
+ * event-loop iteration.
102
+ *
103
+ * A test that fakes `setImmediate` and drives such a loop for longer than the
104
+ * budget must advance the fake timers, or the loop waits for them.
105
+ */
106
+ export class EventLoopYielder {
107
+ budgetMs;
108
+ now;
109
+ /** Whether a check-phase marker is pending. While it is, the event loop has
110
+ * not reached its check phase since {@link sliceStart}. */
111
+ markerPending = false;
112
+ sliceStart = 0;
113
+ constructor(budgetMs, now = () => performance.now()) {
114
+ this.budgetMs = budgetMs;
115
+ this.now = now;
116
+ }
117
+ maybeYield() {
118
+ const time = this.now();
119
+ if (!this.markerPending) {
120
+ this.markerPending = true;
121
+ this.sliceStart = time;
122
+ setImmediate(() => {
123
+ this.markerPending = false;
124
+ });
125
+ return undefined;
126
+ }
127
+ if (time - this.sliceStart < this.budgetMs)
128
+ return undefined;
129
+ return new Promise((resolve) => setImmediate(resolve));
130
+ }
131
+ }
132
+ /** The longest stretch, in milliseconds, that a message loop keeps the event
133
+ * loop before it yields. */
134
+ const MESSAGE_LOOP_BUDGET_MS = 8;
135
+ /** The yielder shared by the loops that forward messages to the client. */
136
+ export const messageLoopYielder = new EventLoopYielder(MESSAGE_LOOP_BUDGET_MS);
79
137
  export function sleep(time) {
80
138
  return new Promise((resolve) => setTimeout(resolve, time));
81
139
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.88.1-preview.6",
6
+ "version": "0.88.1-preview.7",
7
7
  "description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
8
8
  "main": "dist/lib.js",
9
9
  "types": "dist/lib.d.ts",