linkshell-cli 0.10.18 → 0.10.20

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 (112) hide show
  1. package/README.md +7 -2
  2. package/dist/cli/src/commands/screen.js +2 -2
  3. package/dist/cli/src/commands/screen.js.map +1 -1
  4. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  5. package/dist/host/src/acp-settings.d.ts +12 -0
  6. package/dist/host/src/acp-settings.js +41 -0
  7. package/dist/host/src/acp-settings.js.map +1 -0
  8. package/dist/host/src/carry.js +13 -2
  9. package/dist/host/src/carry.js.map +1 -1
  10. package/dist/host/src/drivers/acp/client-services.d.ts +29 -0
  11. package/dist/host/src/drivers/acp/client-services.js +192 -0
  12. package/dist/host/src/drivers/acp/client-services.js.map +1 -0
  13. package/dist/host/src/drivers/acp/connection.d.ts +40 -7
  14. package/dist/host/src/drivers/acp/connection.js +132 -103
  15. package/dist/host/src/drivers/acp/connection.js.map +1 -1
  16. package/dist/host/src/drivers/acp/driver.d.ts +95 -2
  17. package/dist/host/src/drivers/acp/driver.js +628 -53
  18. package/dist/host/src/drivers/acp/driver.js.map +1 -1
  19. package/dist/host/src/drivers/acp/editor.d.ts +31 -0
  20. package/dist/host/src/drivers/acp/editor.js +242 -0
  21. package/dist/host/src/drivers/acp/editor.js.map +1 -0
  22. package/dist/host/src/drivers/acp/mapper.d.ts +2 -1
  23. package/dist/host/src/drivers/acp/mapper.js +88 -35
  24. package/dist/host/src/drivers/acp/mapper.js.map +1 -1
  25. package/dist/host/src/drivers/acp/mcp.d.ts +20 -0
  26. package/dist/host/src/drivers/acp/mcp.js +139 -0
  27. package/dist/host/src/drivers/acp/mcp.js.map +1 -0
  28. package/dist/host/src/drivers/acp/questions.js +14 -0
  29. package/dist/host/src/drivers/acp/questions.js.map +1 -1
  30. package/dist/host/src/drivers/acp/updates.d.ts +10 -0
  31. package/dist/host/src/drivers/acp/updates.js +137 -0
  32. package/dist/host/src/drivers/acp/updates.js.map +1 -0
  33. package/dist/host/src/drivers/claude/activity.d.ts +1 -0
  34. package/dist/host/src/drivers/claude/activity.js +17 -3
  35. package/dist/host/src/drivers/claude/activity.js.map +1 -1
  36. package/dist/host/src/drivers/claude/children.d.ts +7 -0
  37. package/dist/host/src/drivers/claude/children.js +31 -2
  38. package/dist/host/src/drivers/claude/children.js.map +1 -1
  39. package/dist/host/src/drivers/claude/driver.d.ts +4 -1
  40. package/dist/host/src/drivers/claude/driver.js +42 -1
  41. package/dist/host/src/drivers/claude/driver.js.map +1 -1
  42. package/dist/host/src/drivers/claude/tasks.d.ts +3 -3
  43. package/dist/host/src/drivers/claude/transcript.d.ts +497 -38
  44. package/dist/host/src/drivers/codex/archives.d.ts +2 -0
  45. package/dist/host/src/drivers/codex/archives.js +18 -0
  46. package/dist/host/src/drivers/codex/archives.js.map +1 -0
  47. package/dist/host/src/drivers/codex/attention.js +2 -2
  48. package/dist/host/src/drivers/codex/attention.js.map +1 -1
  49. package/dist/host/src/drivers/codex/driver.d.ts +4 -0
  50. package/dist/host/src/drivers/codex/driver.js +51 -2
  51. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  52. package/dist/host/src/drivers/registry.d.ts +6 -0
  53. package/dist/host/src/drivers/registry.js +8 -0
  54. package/dist/host/src/drivers/registry.js.map +1 -1
  55. package/dist/host/src/drivers/types.d.ts +38 -1
  56. package/dist/host/src/fs.d.ts +3 -3
  57. package/dist/host/src/host.js +21 -3
  58. package/dist/host/src/host.js.map +1 -1
  59. package/dist/host/src/hub.d.ts +250 -2
  60. package/dist/host/src/hub.js +184 -13
  61. package/dist/host/src/hub.js.map +1 -1
  62. package/dist/host/src/input.d.ts +5 -3
  63. package/dist/host/src/input.js +8 -6
  64. package/dist/host/src/input.js.map +1 -1
  65. package/dist/host/src/questions.d.ts +3 -1
  66. package/dist/host/src/questions.js +33 -7
  67. package/dist/host/src/questions.js.map +1 -1
  68. package/dist/host/src/rpc/server.js +14 -0
  69. package/dist/host/src/rpc/server.js.map +1 -1
  70. package/dist/host/src/screen.js +5 -5
  71. package/dist/host/src/screen.js.map +1 -1
  72. package/dist/host/src/slim.js +15 -4
  73. package/dist/host/src/slim.js.map +1 -1
  74. package/dist/host/src/store.d.ts +2 -0
  75. package/dist/host/src/store.js +16 -2
  76. package/dist/host/src/store.js.map +1 -1
  77. package/dist/host/src/terminals.d.ts +8 -3
  78. package/dist/host/src/terminals.js +3 -2
  79. package/dist/host/src/terminals.js.map +1 -1
  80. package/dist/wire/src/acp.d.ts +510 -0
  81. package/dist/wire/src/acp.js +39 -0
  82. package/dist/wire/src/acp.js.map +1 -0
  83. package/dist/wire/src/async-questions.d.ts +2 -2
  84. package/dist/wire/src/computer-preview.d.ts +2 -2
  85. package/dist/wire/src/content.d.ts +465 -0
  86. package/dist/wire/src/content.js +58 -0
  87. package/dist/wire/src/content.js.map +1 -0
  88. package/dist/wire/src/editor.d.ts +702 -0
  89. package/dist/wire/src/editor.js +23 -0
  90. package/dist/wire/src/editor.js.map +1 -0
  91. package/dist/wire/src/index.d.ts +3 -0
  92. package/dist/wire/src/index.js +3 -0
  93. package/dist/wire/src/index.js.map +1 -1
  94. package/dist/wire/src/model.d.ts +1383 -79
  95. package/dist/wire/src/model.js +20 -1
  96. package/dist/wire/src/model.js.map +1 -1
  97. package/dist/wire/src/peer.d.ts +1 -0
  98. package/dist/wire/src/peer.js +22 -6
  99. package/dist/wire/src/peer.js.map +1 -1
  100. package/dist/wire/src/questions.d.ts +3 -0
  101. package/dist/wire/src/questions.js +65 -0
  102. package/dist/wire/src/questions.js.map +1 -0
  103. package/dist/wire/src/relay.d.ts +4 -4
  104. package/dist/wire/src/rpc.d.ts +19023 -2518
  105. package/dist/wire/src/rpc.js +41 -3
  106. package/dist/wire/src/rpc.js.map +1 -1
  107. package/dist/wire/src/updates.d.ts +3848 -1114
  108. package/dist/wire/src/updates.js +42 -40
  109. package/dist/wire/src/updates.js.map +1 -1
  110. package/dist/wire/src/workflow.d.ts +22 -22
  111. package/package.json +4 -4
  112. package/src/commands/screen.ts +2 -2
@@ -1,4 +1,4 @@
1
- import { RpcError, questionReplies, updateAsyncQuestions, sessionIdFor, } from "@linkshell/wire";
1
+ import { RpcError, questionReplies, questionAnswerError, updateAsyncQuestions, sessionIdFor, } from "@linkshell/wire";
2
2
  import { inOrder } from "./drivers/acp/driver.js";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
@@ -91,10 +91,13 @@ export class SessionHub {
91
91
  store;
92
92
  log;
93
93
  home;
94
+ acpServices;
94
95
  drivers = new Map();
95
96
  live = new Map();
96
97
  summaryListeners = new Set();
97
98
  removedListeners = new Set();
99
+ interactionListeners = new Set();
100
+ agentListeners = new Set();
98
101
  auth = new Map();
99
102
  /** Terminals (`linkshell <agent>`) currently driving handoff sessions. */
100
103
  desktops = new Map();
@@ -104,6 +107,29 @@ export class SessionHub {
104
107
  authRefreshing;
105
108
  previews;
106
109
  driverHost = {
110
+ authChanged: (agent) => {
111
+ const driver = this.drivers.get(agent);
112
+ if (!driver)
113
+ return;
114
+ void this.refreshAuth(driver).then(() => { const info = this.agents().find((entry) => entry.id === agent); if (info)
115
+ for (const listener of this.agentListeners)
116
+ listener(info); }).catch(() => { });
117
+ },
118
+ agentSettings: (agent) => this.acpServices.settings.settings(agent),
119
+ loginTerminal: async (agent, launch) => {
120
+ if (!this.acpServices)
121
+ throw RpcError.app("not_supported", "终端登录不可用");
122
+ return this.acpServices.loginTerminal(agent, launch);
123
+ },
124
+ interaction: (agent, request) => { for (const listener of this.interactionListeners)
125
+ listener(agent, request); },
126
+ notice: (agent, nativeId, notice) => {
127
+ const live = this.live.get(sessionIdFor(agent, nativeId));
128
+ if (!live || live.importing)
129
+ return;
130
+ for (const subscriber of live.subscribers)
131
+ subscriber.notice?.(notice);
132
+ },
107
133
  tasks: (agent, nativeId) => {
108
134
  const id = sessionIdFor(agent, nativeId);
109
135
  return this.store.getSession(id) ? this.tasks(id) : [];
@@ -151,10 +177,13 @@ export class SessionHub {
151
177
  };
152
178
  constructor(store, drivers, log = () => { },
153
179
  /** Where the host keeps its own files: worktrees go under it. */
154
- home = join(homedir(), ".linkshell")) {
180
+ home = join(homedir(), ".linkshell"), acpServices) {
155
181
  this.store = store;
156
182
  this.log = log;
157
183
  this.home = home;
184
+ this.acpServices = acpServices;
185
+ if (!acpServices)
186
+ this.driverHost.agentSettings = undefined;
158
187
  const capture = process.platform === "darwin" ? new MacPreviewCapture(log) : undefined;
159
188
  this.previews = new ComputerPreviews({
160
189
  load: (id) => store.getDriverState(id, "computer-preview"),
@@ -167,6 +196,8 @@ export class SessionHub {
167
196
  this.drivers.set(driver.id, driver);
168
197
  }
169
198
  async start(options = {}) {
199
+ for (const id of this.store.resetLiveSessions())
200
+ this.pendingQuestions.delete(id);
170
201
  await Promise.all([...this.drivers.values()].map(async (driver) => {
171
202
  try {
172
203
  const status = await driver.start(this.driverHost);
@@ -177,6 +208,7 @@ export class SessionHub {
177
208
  return;
178
209
  for (const session of await driver.listSessions(100))
179
210
  this.recordDiscovered(driver.id, session);
211
+ await this.refreshArchives(driver);
180
212
  }
181
213
  catch (error) {
182
214
  this.log(`[hub] ${driver.id} failed to start: ${error instanceof Error ? error.message : String(error)}`);
@@ -198,12 +230,25 @@ export class SessionHub {
198
230
  try {
199
231
  for (const session of await driver.listSessions(limit))
200
232
  this.recordDiscovered(driver.id, session);
233
+ await this.refreshArchives(driver);
201
234
  }
202
235
  catch (error) {
203
236
  this.log(`[hub] ${driver.id} discovery failed: ${error instanceof Error ? error.message : String(error)}`);
204
237
  }
205
238
  }));
206
239
  }
240
+ async refreshArchives(driver) {
241
+ for (const nativeId of await driver.archivedSessions?.() ?? []) {
242
+ const before = this.store.getSession(sessionIdFor(driver.id, nativeId));
243
+ // Only reconcile sessions already known here; never import the entire native archive.
244
+ if (!before || (before.archived && before.state === "idle"))
245
+ continue;
246
+ this.recordDiscovered(driver.id, {
247
+ nativeId, cwd: before.cwd, createdAt: before.createdAt, updatedAt: before.updatedAt,
248
+ archived: true, state: "idle", asyncQuestions: [],
249
+ });
250
+ }
251
+ }
207
252
  async stop() {
208
253
  if (this.discoveryTimer)
209
254
  clearInterval(this.discoveryTimer);
@@ -225,6 +270,85 @@ export class SessionHub {
225
270
  };
226
271
  });
227
272
  }
273
+ onInteraction(listener) {
274
+ this.interactionListeners.add(listener);
275
+ return () => { this.interactionListeners.delete(listener); };
276
+ }
277
+ onAgent(listener) { this.agentListeners.add(listener); return () => { this.agentListeners.delete(listener); }; }
278
+ async acpInfo(agent, sessionId) {
279
+ const driver = this.requireDriver(agent);
280
+ if (!driver.acpInfo)
281
+ throw RpcError.app("not_supported", "这个 AI 不使用 ACP");
282
+ const session = sessionId ? this.getSession(sessionId) : undefined;
283
+ if (session && session.agent !== agent)
284
+ throw RpcError.app("invalid_params", "会话与 AI 不匹配");
285
+ return driver.acpInfo(session?.nativeId);
286
+ }
287
+ pendingInteractions() { return [...this.drivers.values()].flatMap((driver) => (driver.pendingInteractions?.() ?? []).map((request) => ({ agent: driver.id, request }))); }
288
+ async editor(input) {
289
+ const driver = this.requireDriver(input.agent);
290
+ if (!driver.editor)
291
+ throw RpcError.app("not_supported", "这个 AI 不支持编辑建议");
292
+ return driver.editor(input);
293
+ }
294
+ async configureAcp(agent, settings, sessionId) {
295
+ const driver = this.requireDriver(agent);
296
+ if (!driver.configureAcp || !this.acpServices)
297
+ throw RpcError.app("not_supported", "这个 AI 不支持 ACP 配置");
298
+ const session = sessionId ? this.getSession(sessionId) : undefined;
299
+ if (session && session.agent !== agent)
300
+ throw RpcError.app("invalid_params", "会话与 AI 不匹配");
301
+ await driver.configureAcp(settings, session?.nativeId);
302
+ if (!session)
303
+ this.acpServices.settings.setSettings(agent, settings);
304
+ }
305
+ async authenticate(agent, methodId, logout) {
306
+ const driver = this.requireDriver(agent);
307
+ if (!driver.authenticate)
308
+ throw RpcError.app("not_supported", "这个 AI 不支持 ACP 登录");
309
+ const result = await driver.authenticate(methodId, logout);
310
+ await this.refreshAuth(driver);
311
+ return result;
312
+ }
313
+ async respondInteraction(agent, requestId, response) {
314
+ const driver = this.requireDriver(agent);
315
+ if (!driver.respondInteraction)
316
+ throw RpcError.app("not_supported", "这个 AI 不支持此交互");
317
+ await driver.respondInteraction(requestId, response);
318
+ }
319
+ async providers(agent, operation, config) {
320
+ const driver = this.requireDriver(agent);
321
+ if (!driver.providers)
322
+ throw RpcError.app("not_supported", "这个 AI 不支持供应商配置");
323
+ return driver.providers(operation, config);
324
+ }
325
+ async customAgents(save, remove) {
326
+ const services = this.acpServices;
327
+ if (!services)
328
+ throw RpcError.app("not_supported", "自定义 ACP 配置不可用");
329
+ if (save && remove)
330
+ throw RpcError.app("invalid_params", "不能同时保存和移除 Agent");
331
+ const id = save?.id ?? remove;
332
+ if (id) {
333
+ const existing = this.drivers.get(id);
334
+ if (existing && !services.settings.agents().some((agent) => agent.id === id))
335
+ throw RpcError.app("invalid_params", "不能覆盖或移除内置 AI");
336
+ const driver = save ? services.createDriver(save) : undefined;
337
+ if (existing) {
338
+ await existing.configureAcp?.(services.settings.settings(id));
339
+ await existing.stop();
340
+ this.drivers.delete(id);
341
+ }
342
+ if (save && driver) {
343
+ services.settings.saveAgent(save);
344
+ this.drivers.set(id, driver);
345
+ await driver.start(this.driverHost);
346
+ }
347
+ else
348
+ services.settings.removeAgent(id);
349
+ }
350
+ return services.settings.agents();
351
+ }
228
352
  /** Re-checks login state in the background when the cached answer is older than `maxAgeMs`. */
229
353
  refreshAuthIfStale(maxAgeMs = 60_000) {
230
354
  if (this.authRefreshing)
@@ -336,9 +460,12 @@ export class SessionHub {
336
460
  if (first) {
337
461
  decorated.permission = {
338
462
  requestId: first.requestId,
463
+ childSessionId: first.childSessionId,
339
464
  toolCallId: first.toolCallId,
340
465
  title: first.title,
341
466
  detail: first.detail,
467
+ tool: first.tool,
468
+ url: first.url,
342
469
  options: first.options,
343
470
  questions: first.questions,
344
471
  };
@@ -511,9 +638,13 @@ export class SessionHub {
511
638
  // Serve the log we have; not_ready sessions attach once they get a turn.
512
639
  if (!(error instanceof RpcError && error.appCode === "not_ready")) {
513
640
  this.log(`[hub] attach ${sessionId} failed: ${error instanceof Error ? error.message : String(error)}`);
514
- if (error instanceof RpcError && error.appCode === "not_logged_in") {
515
- this.commit(sessionId, { sessionUpdate: "ls_error", code: error.appCode, message: error.message });
516
- }
641
+ this.commit(sessionId, { sessionUpdate: "ls_status", state: "error" });
642
+ this.commit(sessionId, {
643
+ sessionUpdate: "ls_error",
644
+ code: error instanceof RpcError ? error.appCode ?? "history_unavailable" : "history_unavailable",
645
+ message: `读取会话消息失败:${error instanceof Error ? error.message : String(error)}`,
646
+ hint: "重新打开会话可重试",
647
+ }, undefined, summary.updatedAt);
517
648
  }
518
649
  }
519
650
  }
@@ -654,6 +785,8 @@ export class SessionHub {
654
785
  task: detail?.task ?? call.title,
655
786
  agentType: detail?.agentType,
656
787
  name: detail?.name,
788
+ nativeSessionId: detail?.nativeSessionId,
789
+ canCancel: detail?.canCancel,
657
790
  running,
658
791
  failed: (workflow?.state ? workflow.state === "failed" : detail?.state ? detail.state === "failed" : state.status === "failed") || undefined,
659
792
  startedAt: workflow?.startedAt ?? event.ts,
@@ -665,6 +798,13 @@ export class SessionHub {
665
798
  }
666
799
  return [...agents.values()].reverse();
667
800
  }
801
+ async cancelSubagent(sessionId, nativeSessionId) {
802
+ const session = this.getSession(sessionId), driver = this.requireDriver(session.agent);
803
+ if (!driver.cancelSubagent)
804
+ throw RpcError.app("not_supported", "这个 AI 不支持单独停止子代理");
805
+ await this.ensureAttached(sessionId);
806
+ await driver.cancelSubagent(session.nativeId, nativeSessionId);
807
+ }
668
808
  subagentsOf(sessionId, live) {
669
809
  if (!live.subagents) {
670
810
  live.subagents = new Map();
@@ -1012,9 +1152,11 @@ export class SessionHub {
1012
1152
  const other = question.other ? answer.other?.trim() || undefined : undefined;
1013
1153
  return [{ id: question.id, values, other }];
1014
1154
  });
1015
- const missing = request.questions.find((question) => question.required && !kept.some((answer) => answer.id === question.id && (answer.values.some(Boolean) || answer.other)));
1016
- if (missing)
1017
- throw RpcError.app("invalid_params", `还没回答:${missing.header ?? missing.text}`);
1155
+ for (const question of request.questions) {
1156
+ const error = questionAnswerError(question, kept.find((answer) => answer.id === question.id));
1157
+ if (error)
1158
+ throw RpcError.app("invalid_params", error);
1159
+ }
1018
1160
  await driver.answerQuestion(summary.nativeId, requestId, kept);
1019
1161
  }
1020
1162
  async desktopLaunch(agent, args, sessionId, context) {
@@ -1153,7 +1295,7 @@ export class SessionHub {
1153
1295
  if (live.importChanged && stored)
1154
1296
  this.emitSummary(stored);
1155
1297
  }
1156
- live.attached = true;
1298
+ live.attached = !this.store.getSession(sessionId)?.archived;
1157
1299
  }
1158
1300
  finally {
1159
1301
  const buffered = live.buffer ?? [];
@@ -1183,7 +1325,7 @@ export class SessionHub {
1183
1325
  session = { ...session, updatedAt: before.updatedAt };
1184
1326
  }
1185
1327
  }
1186
- const { summary, created } = this.store.upsertSession({
1328
+ const stored = this.store.upsertSession({
1187
1329
  id: sessionIdFor(agent, session.nativeId),
1188
1330
  agent,
1189
1331
  nativeId: session.nativeId,
@@ -1195,9 +1337,14 @@ export class SessionHub {
1195
1337
  createdAt: session.createdAt,
1196
1338
  updatedAt: session.updatedAt,
1197
1339
  });
1340
+ const { created } = stored;
1341
+ const summary = session.archived !== undefined && session.archived !== stored.summary.archived
1342
+ ? this.store.patchSession(stored.summary.id, { archived: session.archived })
1343
+ : stored.summary;
1198
1344
  const changed = !!before &&
1199
1345
  (before.title !== summary.title ||
1200
1346
  before.state !== summary.state ||
1347
+ before.archived !== summary.archived ||
1201
1348
  before.updatedAt !== summary.updatedAt ||
1202
1349
  before.cwd !== summary.cwd ||
1203
1350
  before.model !== summary.model);
@@ -1242,6 +1389,8 @@ export class SessionHub {
1242
1389
  // A finished turn can't still be waiting on approvals; clear stale cards.
1243
1390
  if (update.sessionUpdate === "ls_turn" && update.state === "ended" && !update.parentToolCallId) {
1244
1391
  for (const requestId of [...live.permissions.keys()]) {
1392
+ if (live.permissions.get(requestId)?.childSessionId)
1393
+ continue;
1245
1394
  this.commit(sessionId, { sessionUpdate: "ls_permission_resolved", requestId });
1246
1395
  }
1247
1396
  if (!live.importing)
@@ -1278,6 +1427,18 @@ export class SessionHub {
1278
1427
  activityChanged = true;
1279
1428
  };
1280
1429
  switch (update.sessionUpdate) {
1430
+ case "ls_message": {
1431
+ if (update.parentToolCallId || !update.content)
1432
+ break;
1433
+ const text = update.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1434
+ if (update.role === "user" && !before.title && text.trim())
1435
+ patch.title = compact(text, TITLE_LENGTH);
1436
+ if (update.role === "agent" && text.trim())
1437
+ patch.preview = compact(text, PREVIEW_LENGTH);
1438
+ if (live.turnActive && update.role === "agent")
1439
+ setActivity({ kind: "responding" });
1440
+ break;
1441
+ }
1281
1442
  case "user_message_chunk": {
1282
1443
  const text = update.content.type === "text" ? update.content.text : "";
1283
1444
  if (text && !before.title)
@@ -1315,10 +1476,15 @@ export class SessionHub {
1315
1476
  live.turnActive = update.state === "started";
1316
1477
  setActivity(live.turnActive ? { kind: "thinking" } : undefined);
1317
1478
  // Leftover permissions are cleared right after a turn ends (see commit).
1318
- patch.state = live.turnActive ? "running" : update.stopReason === "error" ? "error" : "idle";
1479
+ patch.state = live.turnActive ? "running" : [...live.permissions.values()].some((permission) => permission.childSessionId) ? "waiting" : update.stopReason === "error" ? "error" : "idle";
1319
1480
  break;
1320
1481
  case "ls_status":
1321
1482
  patch.state = update.state;
1483
+ if (!live.importing) {
1484
+ live.turnActive = update.turnActive ?? (update.state === "running" || update.state === "waiting");
1485
+ if (!live.turnActive)
1486
+ setActivity(undefined);
1487
+ }
1322
1488
  break;
1323
1489
  case "ls_permission":
1324
1490
  live.permissions.set(update.requestId, update);
@@ -1332,8 +1498,13 @@ export class SessionHub {
1332
1498
  patch.state = live.turnActive ? "running" : "idle";
1333
1499
  break;
1334
1500
  case "session_info_update":
1335
- if (update.title)
1336
- patch.title = compact(update.title, TITLE_LENGTH);
1501
+ if (update.title !== undefined)
1502
+ patch.title = update.title === null ? null : compact(update.title, TITLE_LENGTH);
1503
+ if (update.updatedAt !== undefined) {
1504
+ const updatedAt = update.updatedAt === null ? before.createdAt : Date.parse(update.updatedAt);
1505
+ if (Number.isFinite(updatedAt))
1506
+ patch.updatedAt = updatedAt;
1507
+ }
1337
1508
  if (update.model)
1338
1509
  patch.model = update.model;
1339
1510
  break;