@cocorograph/hub-agent 0.8.120 → 0.8.122

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocorograph/hub-agent",
3
- "version": "0.8.120",
3
+ "version": "0.8.122",
4
4
  "description": "Hub Hosted Cockpit のローカル常駐 agent。Hub と outbound WSS で接続し、ローカルの Claude / Codex チャットと tmux セッションを中継する。",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -98,6 +98,8 @@ export class CodexAppServerClient extends EventEmitter {
98
98
  "COCKPIT_SIBLING_SELF_KIND",
99
99
  "COCKPIT_SIBLING_CWD",
100
100
  "COCKPIT_SIBLING_TOKEN",
101
+ "COCKPIT_DELEGATE_DISPLAY_TOKEN",
102
+ "COCKPIT_DELEGATE_EVENT_ACK_VERSION",
101
103
  ]) {
102
104
  const val = spawnEnv[key]
103
105
  // TOML 文字列としてクォート (パスの空白/特殊文字対策)。値に " は含まれない
@@ -4204,7 +4204,7 @@ export class CodexStreamBridge extends EventEmitter {
4204
4204
  }
4205
4205
 
4206
4206
  /** Display events never start a turn and only target an exact thread in this cwd. */
4207
- emitToStream(cwd, sessionId, event) {
4207
+ emitToStream(cwd, sessionId, event, notificationName = "cockpit/review_event") {
4208
4208
  if (!cwd || !sessionId) return { status: "not_found" }
4209
4209
  const matches = [...this._liveSessions()].filter((s) => s.cwd === cwd && s.threadId === sessionId)
4210
4210
  if (matches.length !== 1) return { status: matches.length ? "ambiguous" : "not_found" }
@@ -4214,7 +4214,7 @@ export class CodexStreamBridge extends EventEmitter {
4214
4214
  producer_seq: session.seq,
4215
4215
  thread_id: session.threadId,
4216
4216
  cwd: session.cwd,
4217
- notification: { method: "cockpit/review_event", params: event },
4217
+ notification: { method: notificationName, params: event },
4218
4218
  })
4219
4219
  return { status: "delivered", session: this._describe(session) }
4220
4220
  }
@@ -0,0 +1,80 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
4
+ import { paths } from "./config.mjs"
5
+
6
+ /**
7
+ * Publish a complete key atomically: write a temp file in the same directory, then link(2) it to
8
+ * the final name. link fails with EEXIST if another process won, so the published file is always
9
+ * complete and never overwritten (overwriting would invalidate tokens already handed out).
10
+ * An existing malformed key is NOT regenerated here; key() rejects it so the failure stays visible.
11
+ */
12
+ function publishKey(dir, file) {
13
+ const tmp = path.join(dir, `key.${process.pid}.${randomBytes(8).toString("hex")}.tmp`)
14
+ try {
15
+ const fd = fs.openSync(tmp, "wx", 0o600)
16
+ try {
17
+ fs.writeFileSync(fd, randomBytes(32))
18
+ fs.fsyncSync(fd)
19
+ } finally { fs.closeSync(fd) }
20
+ try {
21
+ fs.linkSync(tmp, file)
22
+ } catch (err) {
23
+ if (err.code !== "EEXIST") throw new Error("delegate display auth key creation failed")
24
+ }
25
+ } finally {
26
+ try { fs.unlinkSync(tmp) } catch {}
27
+ }
28
+ }
29
+
30
+ function key() {
31
+ const dir = process.env.HUB_DELEGATE_DISPLAY_AUTH_DIR || path.join(paths.configDir, "delegate-display-auth")
32
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
33
+ const directory = fs.lstatSync(dir)
34
+ if (!directory.isDirectory() || directory.isSymbolicLink() || process.getuid?.() == null ||
35
+ directory.uid !== process.getuid() || (directory.mode & 0o777) !== 0o700) {
36
+ throw new Error("unsafe delegate display auth directory")
37
+ }
38
+ const file = path.join(dir, "key")
39
+ let info
40
+ try {
41
+ info = fs.lstatSync(file)
42
+ } catch (err) {
43
+ if (err.code !== "ENOENT") throw err
44
+ publishKey(dir, file)
45
+ info = fs.lstatSync(file)
46
+ }
47
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid() ||
48
+ (info.mode & 0o777) !== 0o600 || info.size !== 32) {
49
+ throw new Error("unsafe delegate display auth key")
50
+ }
51
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW)
52
+ try {
53
+ const opened = fs.fstatSync(fd)
54
+ if (!opened.isFile() || opened.ino !== info.ino || opened.dev !== info.dev ||
55
+ opened.uid !== info.uid || (opened.mode & 0o777) !== 0o600 || opened.size !== 32) {
56
+ throw new Error("delegate display auth key changed while reading")
57
+ }
58
+ const bytes = Buffer.alloc(33)
59
+ let length = 0
60
+ while (length < bytes.length) {
61
+ const count = fs.readSync(fd, bytes, length, bytes.length - length, null)
62
+ if (!count) break
63
+ length += count
64
+ }
65
+ if (length !== 32) throw new Error("invalid delegate display auth key length")
66
+ return bytes.subarray(0, 32)
67
+ } finally { fs.closeSync(fd) }
68
+ }
69
+
70
+ export function displayTokenForCwd(cwd) {
71
+ if (typeof cwd !== "string" || !cwd) throw new Error("invalid delegate display cwd")
72
+ return "v1." + createHmac("sha256", key()).update("delegate-event\0" + cwd).digest("base64url")
73
+ }
74
+
75
+ export function verifyDisplayToken(cwd, token) {
76
+ if (typeof cwd !== "string" || !cwd || typeof token !== "string" || !/^v1\.[A-Za-z0-9_-]{43}$/.test(token)) return false
77
+ const expected = Buffer.from(displayTokenForCwd(cwd))
78
+ const actual = Buffer.from(token)
79
+ return actual.length === expected.length && timingSafeEqual(actual, expected)
80
+ }
@@ -0,0 +1,332 @@
1
+ import fs from "node:fs/promises"
2
+ import { constants } from "node:fs"
3
+ import path from "node:path"
4
+ import { createHash, randomUUID } from "node:crypto"
5
+ import { truncateString } from "./truncate-string.mjs"
6
+ import { paths } from "./config.mjs"
7
+
8
+ export const DELEGATION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
9
+ export const DELEGATION_SESSION_LIMIT = 1000
10
+ export const DELEGATION_SOFT_BYTES = 4 * 1024 * 1024
11
+ export const DELEGATION_HARD_BYTES = 5 * 1024 * 1024
12
+ const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
13
+ const TERMINAL_DELEGATE_AGENT_MAX_LENGTH = 200
14
+ const TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH = 2_000
15
+
16
+ function compactTerminalEvent(event, includePrompt = false) {
17
+ const storedEvent = {}
18
+ for (const field of [
19
+ "type",
20
+ "subtype",
21
+ "delegation_id",
22
+ "phase",
23
+ "background",
24
+ "timestamp",
25
+ ]) {
26
+ if (event[field] !== undefined) storedEvent[field] = event[field]
27
+ }
28
+
29
+ if (typeof event.agent === "string") {
30
+ storedEvent.agent = truncateString(
31
+ event.agent,
32
+ TERMINAL_DELEGATE_AGENT_MAX_LENGTH,
33
+ (length) => `\n\n[エージェント名はここで打ち切られています(全 ${length} 文字)]`,
34
+ )
35
+ }
36
+ if (typeof event.description === "string") {
37
+ storedEvent.description = truncateString(
38
+ event.description,
39
+ TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH,
40
+ (length) => `\n\n[説明はここで打ち切られています(全 ${length} 文字)]`,
41
+ )
42
+ }
43
+ if (includePrompt && typeof event.prompt === "string") {
44
+ storedEvent.prompt = truncateString(
45
+ event.prompt,
46
+ TERMINAL_DELEGATE_PROMPT_MAX_LENGTH,
47
+ (length) =>
48
+ `\n\n[終了済み委譲の依頼本文はここで打ち切られています(全 ${length} 文字)]`,
49
+ )
50
+ }
51
+
52
+ return storedEvent
53
+ }
54
+
55
+ const FIELDS = new Set([
56
+ "type", "subtype", "delegation_id", "phase", "agent", "description", "background",
57
+ "prompt", "pid", "pid_started_at", "timestamp",
58
+ ])
59
+ function validEvent(event) {
60
+ return event && typeof event === "object" && !Array.isArray(event) &&
61
+ Object.keys(event).every((key) => FIELDS.has(key)) &&
62
+ event.type === "system" && event.subtype === "codex_delegate" &&
63
+ typeof event.delegation_id === "string" && event.delegation_id.length > 0 && event.delegation_id.length <= 200 &&
64
+ ["started", "completed", "failed", "stopped"].includes(event.phase) &&
65
+ (event.phase !== "started" || (typeof event.agent === "string" && event.agent.length > 0)) &&
66
+ ["agent", "description", "prompt", "pid_started_at"].every((key) => event[key] === undefined || typeof event[key] === "string") &&
67
+ (event.background === undefined || typeof event.background === "boolean") &&
68
+ (event.pid === undefined || (Number.isInteger(event.pid) && event.pid > 0)) &&
69
+ typeof event.timestamp === "string" && Number.isFinite(Date.parse(event.timestamp))
70
+ }
71
+
72
+ /**
73
+ * 既定の保存先。`HUB_DELEGATION_EVENTS_DIR` があればそれを使う(テストが実運用の
74
+ * `~/.hub/delegation-events` へ書き込まないよう、test/setup-env.mjs が実行ごとの一時ディレクトリへ向ける)。
75
+ */
76
+ export function defaultDelegationEventsDir() {
77
+ return process.env.HUB_DELEGATION_EVENTS_DIR || path.join(paths.configDir, "delegation-events")
78
+ }
79
+
80
+ /** Single daemon writer. All decisions, snapshots and replay checks share this queue. */
81
+ export class DelegationEventStore {
82
+ constructor(dir = defaultDelegationEventsDir(), now = Date.now, logger) {
83
+ this.dir = dir
84
+ this.now = now
85
+ this.logger = logger
86
+ this.queue = Promise.resolve()
87
+ this.lastSweep = -Infinity
88
+ this.sweeping = false
89
+ }
90
+ _serial(operation) {
91
+ const pending = this.queue.then(operation)
92
+ this.queue = pending.catch(() => {})
93
+ return pending
94
+ }
95
+ _target(cwd, sessionId, kind = "claude") {
96
+ if (!["claude", "codex"].includes(kind)) throw new Error("invalid delegation kind")
97
+ return kind === "codex" ? [cwd, sessionId, kind] : [cwd, sessionId]
98
+ }
99
+ _file(cwd, sessionId, kind = "claude") {
100
+ if ([cwd, sessionId].some((value) => typeof value !== "string" || !value)) throw new Error("invalid delegation target")
101
+ return path.join(this.dir, createHash("sha256").update(JSON.stringify(this._target(cwd, sessionId, kind))).digest("hex") + ".json")
102
+ }
103
+ async _checkDirectory() {
104
+ const info = await fs.lstat(this.dir)
105
+ if (!info.isDirectory() || info.isSymbolicLink() || process.getuid?.() == null ||
106
+ info.uid !== process.getuid() || (info.mode & 0o777) !== 0o700) {
107
+ throw new Error("unsafe delegation directory")
108
+ }
109
+ }
110
+ async _prepare() {
111
+ await fs.mkdir(this.dir, { recursive: true, mode: 0o700 })
112
+ await this._checkDirectory()
113
+ }
114
+ async _read(file, target) {
115
+ await this._checkDirectory()
116
+ let info
117
+ try { info = await fs.lstat(file) } catch (err) {
118
+ if (err.code === "ENOENT") return []
119
+ throw err
120
+ }
121
+ if (!info.isFile() || info.isSymbolicLink() || info.uid !== process.getuid?.() || (info.mode & 0o777) !== 0o600) {
122
+ throw new Error("unsafe delegation store")
123
+ }
124
+ if (info.size > DELEGATION_HARD_BYTES) throw new Error("delegation store exceeds size limit")
125
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW)
126
+ let snapshot
127
+ try {
128
+ const opened = await handle.stat()
129
+ if (opened.ino !== info.ino || opened.dev !== info.dev || opened.size > DELEGATION_HARD_BYTES ||
130
+ opened.uid !== info.uid || (opened.mode & 0o777) !== 0o600) throw new Error("delegation store changed while reading")
131
+ const buffer = Buffer.alloc(DELEGATION_HARD_BYTES + 1)
132
+ let length = 0
133
+ while (length < buffer.length) {
134
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null)
135
+ if (!bytesRead) break
136
+ length += bytesRead
137
+ }
138
+ if (length > DELEGATION_HARD_BYTES) throw new Error("delegation store exceeds size limit")
139
+ snapshot = JSON.parse(buffer.subarray(0, length).toString("utf8"))
140
+ } finally { await handle.close() }
141
+ if (!snapshot || snapshot.schema_version !== 1 || !Array.isArray(snapshot.target) ||
142
+ !(snapshot.target.length === 2 || (snapshot.target.length === 3 && snapshot.target[2] === "codex")) || snapshot.target.some((v) => typeof v !== "string" || !v) ||
143
+ (target && JSON.stringify(snapshot.target) !== JSON.stringify(target)) ||
144
+ this._file(...snapshot.target) !== file || !Array.isArray(snapshot.rows)) throw new Error("invalid delegation snapshot")
145
+ const ids = new Set()
146
+ for (const row of snapshot.rows) {
147
+ if (!row || typeof row !== "object" || Array.isArray(row) ||
148
+ Object.keys(row).some((key) => !["id", "started", "startedAt", "terminal", "at"].includes(key)) ||
149
+ !Number.isFinite(row.at) || ids.has(row.id) || (!row.started && !row.terminal) ||
150
+ (row.started ? !Number.isFinite(row.startedAt) : row.startedAt !== undefined)) throw new Error("invalid delegation row")
151
+ ids.add(row.id)
152
+ for (const key of ["started", "terminal"]) {
153
+ if (row[key] !== undefined && (!validEvent(row[key]) || row[key].delegation_id !== row.id ||
154
+ (key === "started") !== (row[key].phase === "started"))) throw new Error("invalid delegation event")
155
+ }
156
+ }
157
+ return snapshot.rows
158
+ }
159
+ _prune(rows, kept = []) {
160
+ rows = rows.filter((row) => kept.includes(row) || !row.terminal || row.at > this.now() - DELEGATION_RETENTION_MS)
161
+ const oldest = rows.filter((row) => row.terminal).sort((a, b) => a.at - b.at)
162
+ const removed = new Set(oldest.filter((row) => !kept.includes(row))
163
+ .slice(0, Math.max(0, oldest.length - DELEGATION_SESSION_LIMIT)))
164
+ return rows.filter((row) => !removed.has(row))
165
+ }
166
+ rows(cwd, sessionId, kind = "claude") {
167
+ return this._serial(async () => {
168
+ await this._prepare()
169
+ const file = this._file(cwd, sessionId, kind)
170
+ return this._prune(await this._read(file, this._target(cwd, sessionId, kind)))
171
+ })
172
+ }
173
+ record(cwd, sessionId, event, kind = "claude") {
174
+ // Snapshot caller-owned input before waiting for the writer.
175
+ const copy = structuredClone(event)
176
+ return this._serial(() => this._record(cwd, sessionId, copy, kind))
177
+ }
178
+ async _record(cwd, sessionId, event, kind = "claude") {
179
+ if (!validEvent(event)) throw new Error("invalid delegation event")
180
+ await this._prepare()
181
+ const file = this._file(cwd, sessionId, kind)
182
+ const target = this._target(cwd, sessionId, kind)
183
+ const rows = this._prune(await this._read(file, target))
184
+ const result = this._apply(rows, target, event)
185
+ if (result.acceptance === "accept") await this._write(file, result.serialized)
186
+ return result.acceptance
187
+ }
188
+ // Apply to a copy so a rejected event cannot partially mutate the caller's rows.
189
+ _apply(sourceRows, target, event) {
190
+ const rows = structuredClone(sourceRows)
191
+ const result = this._mutate(rows, event)
192
+ if (result.acceptance !== "accept") return result
193
+ return this._finalize(rows, target, [result.row], event.phase === "started")
194
+ }
195
+ /** Mutate rows in place. Rejections return before touching anything. */
196
+ _mutate(rows, event) {
197
+ if (!validEvent(event)) throw new Error("invalid delegation event")
198
+ let row = rows.find((candidate) => candidate.id === event.delegation_id)
199
+ if (event.phase === "started") {
200
+ if (row?.terminal) return { acceptance: "stale" }
201
+ if (row?.started) return { acceptance: "duplicate" }
202
+ } else if (row?.terminal?.phase === event.phase) return { acceptance: "duplicate" }
203
+ if (!row) {
204
+ row = { id: event.delegation_id, at: this.now() }
205
+ rows.push(row)
206
+ }
207
+ row.at = this.now()
208
+ if (event.phase === "started") {
209
+ row.started = event
210
+ row.startedAt = row.at
211
+ } else {
212
+ if (row.started && !row.terminal) row.started = compactTerminalEvent(row.started, true)
213
+ row.terminal = compactTerminalEvent(event)
214
+ }
215
+ return { acceptance: "accept", row }
216
+ }
217
+ /** Prune, enforce the byte limit and serialize once. `kept` rows are never evicted. */
218
+ _finalize(rows, target, kept, started) {
219
+ rows = this._prune(rows, kept)
220
+ const limit = started ? DELEGATION_SOFT_BYTES : DELEGATION_HARD_BYTES
221
+ let bytes = Buffer.byteLength(JSON.stringify({ schema_version: 1, target, rows }))
222
+ if (bytes > limit) {
223
+ // Keep the incoming terminals: evicting them would immediately lose their tombstones.
224
+ const evictable = rows.filter((candidate) => !kept.includes(candidate) && candidate.terminal)
225
+ .sort((a, b) => a.at - b.at)
226
+ const removed = new Set()
227
+ // A kept row always remains, so each eviction drops exactly one separating comma.
228
+ while (bytes > limit && evictable.length) {
229
+ const oldest = evictable.shift()
230
+ removed.add(oldest)
231
+ bytes -= Buffer.byteLength(JSON.stringify(oldest)) + 1
232
+ }
233
+ rows = rows.filter((candidate) => !removed.has(candidate))
234
+ }
235
+ const serialized = JSON.stringify({ schema_version: 1, target, rows })
236
+ if (Buffer.byteLength(serialized) > limit) {
237
+ if (started) return { acceptance: "full" }
238
+ throw new Error("delegation store exceeds size limit")
239
+ }
240
+ return { acceptance: "accept", rows, serialized }
241
+ }
242
+ async _write(file, serialized) {
243
+ const tmp = file + "." + randomUUID() + ".tmp"
244
+ try {
245
+ await fs.writeFile(tmp, serialized, { flag: "wx", mode: 0o600 })
246
+ await this._checkDirectory()
247
+ await fs.rename(tmp, file)
248
+ } finally {
249
+ await fs.unlink(tmp).catch((err) => { if (err.code !== "ENOENT") throw err })
250
+ }
251
+ }
252
+ /** Reconcile the entire liveness plan against one fresh snapshot, then write at most once. */
253
+ replayPlan(cwd, sessionId, plan, emit, kind = "claude") {
254
+ return this._serial(async () => {
255
+ const file = this._file(cwd, sessionId, kind)
256
+ const target = this._target(cwd, sessionId, kind)
257
+ const rows = this._prune(await this._read(file, target))
258
+ const current = new Map(rows.map((row) => [row.id, JSON.stringify(row)]))
259
+ // One working copy for the whole plan; `current` keeps the snapshot used for matching.
260
+ const working = structuredClone(rows)
261
+ const kept = []
262
+ const deliveries = []
263
+ for (const { row, stopped } of plan) {
264
+ if (current.get(row.id) !== JSON.stringify(row)) continue
265
+ if (stopped) {
266
+ try {
267
+ const result = this._mutate(working, stopped)
268
+ if (result.acceptance === "accept") kept.push(result.row)
269
+ } catch (err) {
270
+ this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation stopped persistence failed")
271
+ }
272
+ deliveries.push([stopped])
273
+ } else {
274
+ deliveries.push([row.started, row.terminal].filter(Boolean))
275
+ }
276
+ }
277
+ if (kept.length) {
278
+ try {
279
+ // Like sequential records, only the last accepted terminal is protected; earlier ones
280
+ // in the batch stay evictable so an oversized batch still persists what fits.
281
+ const { serialized } = this._finalize(working, target, kept.slice(-1), false)
282
+ await this._write(file, serialized)
283
+ } catch (err) {
284
+ this.logger?.warn?.({ error: err?.message }, "delegation stopped persistence failed")
285
+ }
286
+ }
287
+ for (const events of deliveries) {
288
+ try { for (const event of events) emit(event) } catch (err) {
289
+ this.logger?.warn?.({ error: err?.message, delegation_id: events[0].delegation_id }, "delegation row replay failed")
290
+ }
291
+ }
292
+ })
293
+ }
294
+ async sweep() {
295
+ if (this.sweeping || this.now() - this.lastSweep < 86400 * 1000) return
296
+ this.sweeping = true
297
+ try {
298
+ const names = await this._serial(async () => {
299
+ try { await this._checkDirectory() } catch (err) {
300
+ if (err.code === "ENOENT") return []
301
+ throw err
302
+ }
303
+ return fs.readdir(this.dir)
304
+ })
305
+ for (const name of names) {
306
+ try { await this._serial(() => this._sweepFile(name)) } catch (err) {
307
+ this.logger?.warn?.({ error: err?.message, file: name }, "delegation sweep file failed")
308
+ }
309
+ }
310
+ // Only a completed pass suppresses the next 24 hours; a directory failure retries.
311
+ this.lastSweep = this.now()
312
+ } finally {
313
+ this.sweeping = false
314
+ }
315
+ }
316
+ async _sweepFile(name) {
317
+ await this._checkDirectory()
318
+ const file = path.join(this.dir, name)
319
+ if (/^[a-f0-9]{64}\.json\.[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}\.tmp$/.test(name)) {
320
+ const info = await fs.lstat(file).catch((err) => { if (err.code !== "ENOENT") throw err })
321
+ if (info?.isFile() && !info.isSymbolicLink() && info.uid === process.getuid?.() &&
322
+ info.mtimeMs <= this.now() - 3600 * 1000) await fs.unlink(file)
323
+ return
324
+ }
325
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) return
326
+ let rows
327
+ try { rows = await this._read(file) } catch { return }
328
+ if (rows.length && rows.every((row) => row.terminal && row.at <= this.now() - DELEGATION_RETENTION_MS)) {
329
+ await fs.unlink(file)
330
+ }
331
+ }
332
+ }
package/src/main.mjs CHANGED
@@ -4365,6 +4365,15 @@ export async function dispatch(msg, ctx) {
4365
4365
  } catch (err) {
4366
4366
  ctx.logger?.warn?.({ err: err?.message }, "failed to replay review events")
4367
4367
  }
4368
+ try {
4369
+ if (info.threadId) {
4370
+ void ctx.siblingInjectBridge
4371
+ ?.replayDelegations?.(resolvedCwd || process.env.HOME || process.cwd(), info.threadId, "codex")
4372
+ ?.catch((err) => ctx.logger?.warn?.({ err: err?.message }, "failed to replay delegation events"))
4373
+ }
4374
+ } catch (err) {
4375
+ ctx.logger?.warn?.({ err: err?.message }, "failed to replay delegation events")
4376
+ }
4368
4377
  const session = ctx.codexBridge.sessions?.get(stream_id)
4369
4378
  if (session && info.resuming) {
4370
4379
  void loadResumeChatContext(session, "codex").catch(() => {})
@@ -71,6 +71,7 @@
71
71
  * 判断材料として **外部契約に残す**。
72
72
  */
73
73
 
74
+ import { verifyDisplayToken } from "./delegate-display-auth.mjs"
74
75
  import { watch } from "node:fs"
75
76
  import { chmod, lstat, mkdir, readFile, writeFile, rename, unlink, readdir } from "node:fs/promises"
76
77
  import { execFile as execFileCallback } from "node:child_process"
@@ -81,6 +82,8 @@ import path from "node:path"
81
82
  import { verifySiblingTokenAny, siblingTokenCwd } from "./sibling-token.mjs"
82
83
  import { describeLiveSessionsUnion } from "./live-sessions.mjs"
83
84
  import { ReviewEventStore, validReviewEvent } from "./review-event-store.mjs"
85
+ import { truncateString } from "./truncate-string.mjs"
86
+ import { DelegationEventStore } from "./delegation-event-store.mjs"
84
87
  import { listChatTargets } from "./chat-targets.mjs"
85
88
 
86
89
  export const SIBLING_REQUESTS_DIR =
@@ -97,26 +100,13 @@ const SIBLING_SWEEP_DEBOUNCE_MS = 25
97
100
  */
98
101
  export const SIBLING_BACKSTOP_SWEEP_MS = 1000
99
102
 
100
- export const ACTIVE_DELEGATIONS_PER_SESSION_LIMIT = 100
101
- export const DELEGATION_LEDGER_RETENTION_MS = 24 * 60 * 60 * 1000
102
103
  export const SIBLING_RESULT_RETENTION_MS = 24 * 60 * 60 * 1000
103
104
  const SIBLING_REQUEST_MAX_BYTES = 1024 * 1024
104
105
  const DELEGATE_PROMPT_MAX_LENGTH = 64_000
105
- const TERMINAL_DELEGATE_PROMPT_MAX_LENGTH = 2_000
106
- const TERMINAL_DELEGATE_AGENT_MAX_LENGTH = 200
107
- const TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH = 2_000
108
106
  const DELEGATION_ID_MAX_LENGTH = 200
109
107
 
110
108
  const execFile = promisify(execFileCallback)
111
109
 
112
- function truncateString(value, maxLength, truncationNote) {
113
- if (value.length <= maxLength) return value
114
- let end = maxLength
115
- const lastCodeUnit = value.charCodeAt(end - 1)
116
- if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1
117
- return value.slice(0, end) + truncationNote(value.length)
118
- }
119
-
120
110
  function truncateDelegatePrompt(prompt) {
121
111
  return truncateString(
122
112
  prompt,
@@ -125,45 +115,6 @@ function truncateDelegatePrompt(prompt) {
125
115
  )
126
116
  }
127
117
 
128
- function compactTerminalEvent(event, includePrompt = false) {
129
- const storedEvent = {}
130
- for (const field of [
131
- "type",
132
- "subtype",
133
- "delegation_id",
134
- "phase",
135
- "background",
136
- "timestamp",
137
- ]) {
138
- if (event[field] !== undefined) storedEvent[field] = event[field]
139
- }
140
-
141
- if (typeof event.agent === "string") {
142
- storedEvent.agent = truncateString(
143
- event.agent,
144
- TERMINAL_DELEGATE_AGENT_MAX_LENGTH,
145
- (length) => `\n\n[エージェント名はここで打ち切られています(全 ${length} 文字)]`,
146
- )
147
- }
148
- if (typeof event.description === "string") {
149
- storedEvent.description = truncateString(
150
- event.description,
151
- TERMINAL_DELEGATE_DESCRIPTION_MAX_LENGTH,
152
- (length) => `\n\n[説明はここで打ち切られています(全 ${length} 文字)]`,
153
- )
154
- }
155
- if (includePrompt && typeof event.prompt === "string") {
156
- storedEvent.prompt = truncateString(
157
- event.prompt,
158
- TERMINAL_DELEGATE_PROMPT_MAX_LENGTH,
159
- (length) =>
160
- `\n\n[終了済み委譲の依頼本文はここで打ち切られています(全 ${length} 文字)]`,
161
- )
162
- }
163
-
164
- return storedEvent
165
- }
166
-
167
118
  /**
168
119
  * `ps` の起動時刻を送受信双方で比較する契約形へ正規化する。
169
120
  * 先頭・末尾の Unicode 空白だけを除去し、日時本体の内部空白は変更しない。
@@ -455,6 +406,7 @@ export class SiblingInjectBridge {
455
406
  isInputBlocked,
456
407
  starter,
457
408
  watchFn = watch,
409
+ delegationDir,
458
410
  } = {}) {
459
411
  this.dir = dir
460
412
  this.claudeBridge = claudeBridge
@@ -478,10 +430,7 @@ export class SiblingInjectBridge {
478
430
  this._starter = starter
479
431
  this._watchFn = watchFn
480
432
  this._newTurnInFlight = 0
481
- /** @type {Map<string, Map<string, {event: object, recordedAt: number}>>} cwd+session ごとの進行中委譲。 */
482
- this._activeDelegations = new Map()
483
- /** @type {Map<string, Map<string, {startedEvent?: object, terminalEvent: object, startedAt?: number, recordedAt: number}>>} 終了済み委譲の再送情報を兼ねた墓標。 */
484
- this._terminalDelegations = new Map()
433
+ this.delegationEvents = new DelegationEventStore(delegationDir, now, logger)
485
434
  }
486
435
 
487
436
  /** restart coordinator が通常/明示注入の bridge await 中を観測する。 */
@@ -510,91 +459,10 @@ export class SiblingInjectBridge {
510
459
  }
511
460
  }
512
461
 
513
- _delegationSessionKey(cwd, sessionId) {
514
- return `${cwd}\u0000${sessionId}`
515
- }
516
-
517
- _pruneDelegations() {
518
- const cutoff = this._now() - DELEGATION_LEDGER_RETENTION_MS
519
- for (const [sessionKey, entries] of this._activeDelegations) {
520
- for (const [delegationId, entry] of entries) {
521
- if (entry.recordedAt < cutoff) entries.delete(delegationId)
522
- }
523
- if (entries.size === 0) this._activeDelegations.delete(sessionKey)
524
- }
525
- for (const [sessionKey, entries] of this._terminalDelegations) {
526
- for (const [delegationId, entry] of entries) {
527
- if (entry.recordedAt < cutoff) entries.delete(delegationId)
528
- }
529
- if (entries.size === 0) this._terminalDelegations.delete(sessionKey)
530
- }
531
- }
532
-
533
- _recordDelegateEvent(cwd, sessionId, event, { prune = true } = {}) {
534
- if (prune) this._pruneDelegations()
535
- const sessionKey = this._delegationSessionKey(cwd, sessionId)
536
- if (event.phase !== "started") {
537
- const activeEntry = this._activeDelegations.get(sessionKey)?.get(event.delegation_id)
538
- const existingTerminalEntry = this._terminalDelegations
539
- .get(sessionKey)
540
- ?.get(event.delegation_id)
541
- this._activeDelegations.get(sessionKey)?.delete(event.delegation_id)
542
- if (this._activeDelegations.get(sessionKey)?.size === 0) {
543
- this._activeDelegations.delete(sessionKey)
544
- }
545
- let terminalEntries = this._terminalDelegations.get(sessionKey)
546
- if (!terminalEntries) {
547
- terminalEntries = new Map()
548
- this._terminalDelegations.set(sessionKey, terminalEntries)
549
- }
550
- terminalEntries.delete(event.delegation_id)
551
- terminalEntries.set(event.delegation_id, {
552
- ...(activeEntry
553
- ? {
554
- startedEvent: compactTerminalEvent(activeEntry.event, true),
555
- startedAt: activeEntry.recordedAt,
556
- }
557
- : existingTerminalEntry?.startedEvent && existingTerminalEntry.startedAt !== undefined
558
- ? {
559
- startedEvent: existingTerminalEntry.startedEvent,
560
- startedAt: existingTerminalEntry.startedAt,
561
- }
562
- : {}),
563
- terminalEvent: compactTerminalEvent(event),
564
- recordedAt: this._now(),
565
- })
566
- while (terminalEntries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
567
- terminalEntries.delete(terminalEntries.keys().next().value)
568
- }
569
- return
570
- }
571
- if (this._terminalDelegations.get(sessionKey)?.has(event.delegation_id)) return
572
- let entries = this._activeDelegations.get(sessionKey)
573
- if (!entries) {
574
- entries = new Map()
575
- this._activeDelegations.set(sessionKey, entries)
576
- }
577
- entries.delete(event.delegation_id)
578
- entries.set(event.delegation_id, { event, recordedAt: this._now() })
579
- while (entries.size > ACTIVE_DELEGATIONS_PER_SESSION_LIMIT) {
580
- entries.delete(entries.keys().next().value)
581
- }
582
- }
583
-
584
- _delegateEventAcceptance(cwd, sessionId, event) {
585
- this._pruneDelegations()
586
- const sessionKey = this._delegationSessionKey(cwd, sessionId)
587
- const terminal = this._terminalDelegations.get(sessionKey)?.get(event.delegation_id)
588
- if (event.phase === "started") {
589
- if (terminal) return "stale"
590
- return this._activeDelegations.get(sessionKey)?.has(event.delegation_id)
591
- ? "duplicate"
592
- : "accept"
593
- }
594
- if (!terminal) return "accept"
595
- // terminal 同士は後から届いた実イベントを最新終端として上書きできる。
596
- // 例: replay が PID 消失から stopped を合成した後に producer の completed が届く。
597
- return terminal.terminalEvent?.phase === event.phase ? "duplicate" : "accept"
462
+ _emitDelegation(kind, cwd, sessionId, event) {
463
+ const bridge = kind === "codex" ? this.codexBridge : this.claudeBridge
464
+ return bridge?.emitToStream?.(cwd, sessionId, event,
465
+ ...(kind === "codex" ? ["cockpit/delegate_event"] : [])) || { status: "not_found" }
598
466
  }
599
467
 
600
468
  _liveClaudeSession(cwd, sessionId) {
@@ -636,54 +504,38 @@ export class SiblingInjectBridge {
636
504
  }
637
505
 
638
506
  /** attach/再接続時に同じ cwd+session の進行中・終了済み委譲を開始順で再送する。 */
639
- async replayDelegations(cwd, sessionId) {
507
+ async replayDelegations(cwd, sessionId, kind = "claude") {
640
508
  if (!cwd || !sessionId) return
641
- this._pruneDelegations()
642
- const sessionKey = this._delegationSessionKey(cwd, sessionId)
643
- const replayEntries = [
644
- ...[...(this._activeDelegations.get(sessionKey) || [])].map(([delegationId, entry]) => ({
645
- delegationId,
646
- entry,
647
- kind: "active",
648
- startedAt: entry.recordedAt,
649
- })),
650
- ...[...(this._terminalDelegations.get(sessionKey) || [])]
651
- .filter(([, entry]) => entry.startedEvent && entry.startedAt !== undefined)
652
- .map(([delegationId, entry]) => ({
653
- delegationId,
654
- entry,
655
- kind: "terminal",
656
- startedAt: entry.startedAt,
657
- })),
658
- ].sort((a, b) => a.startedAt - b.startedAt)
659
-
660
- for (const { delegationId, entry, kind } of replayEntries) {
661
- if (kind === "terminal") {
662
- if (this._terminalDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
663
- this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.startedEvent)
664
- this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.terminalEvent)
665
- continue
666
- }
667
- const alive = await this._delegateProcessAlive(entry.event)
668
- // 生存確認中に終端が届いた場合、古い snapshot の started/stopped を後から流さない。
669
- if (this._activeDelegations.get(sessionKey)?.get(delegationId) !== entry) continue
670
- if (alive) {
671
- this.claudeBridge?.emitToStream?.(cwd, sessionId, entry.event)
672
- continue
673
- }
674
- const stopped = {
675
- type: "system",
676
- subtype: "codex_delegate",
677
- delegation_id: delegationId,
678
- phase: "stopped",
679
- ...(entry.event.agent !== undefined ? { agent: entry.event.agent } : {}),
680
- ...(entry.event.description !== undefined
681
- ? { description: entry.event.description }
682
- : {}),
683
- timestamp: new Date(this._now()).toISOString(),
509
+ try {
510
+ const snapshot = await this.delegationEvents.rows(cwd, sessionId, kind)
511
+ const rows = snapshot.filter((row) => row.started).sort((a, b) => a.startedAt - b.startedAt)
512
+ const plan = []
513
+ for (const row of rows) {
514
+ let alive = true
515
+ try {
516
+ alive = row.terminal || await this._delegateProcessAlive(row.started)
517
+ } catch (err) {
518
+ this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation PID check failed")
519
+ }
520
+ try {
521
+ const stopped = alive ? null : {
522
+ type: "system",
523
+ subtype: "codex_delegate",
524
+ delegation_id: row.id,
525
+ phase: "stopped",
526
+ ...(row.started.agent !== undefined ? { agent: row.started.agent } : {}),
527
+ ...(row.started.description !== undefined ? { description: row.started.description } : {}),
528
+ timestamp: new Date(this._now()).toISOString(),
529
+ }
530
+ plan.push({ row, stopped })
531
+ } catch (err) {
532
+ this.logger?.warn?.({ error: err?.message, delegation_id: row.id }, "delegation row replay failed")
533
+ }
684
534
  }
685
- this._recordDelegateEvent(cwd, sessionId, stopped)
686
- this.claudeBridge?.emitToStream?.(cwd, sessionId, stopped)
535
+ await this.delegationEvents.replayPlan(cwd, sessionId, plan,
536
+ (event) => this._emitDelegation(kind, cwd, sessionId, event), kind)
537
+ } catch (err) {
538
+ this.logger?.warn?.({ error: err?.message }, "delegation replay failed")
687
539
  }
688
540
  }
689
541
 
@@ -794,6 +646,9 @@ export class SiblingInjectBridge {
794
646
 
795
647
  /** 起動時 / 取りこぼし保険: dir 内の全要求ファイルを読む。 */
796
648
  async _sweep({ startup = false } = {}) {
649
+ void this.delegationEvents.sweep().catch((err) => {
650
+ this.logger?.warn?.({ error: err?.message }, "delegation sweep failed")
651
+ })
797
652
  let names = []
798
653
  try {
799
654
  names = await readdir(this.dir)
@@ -901,6 +756,8 @@ export class SiblingInjectBridge {
901
756
  pid,
902
757
  pid_started_at,
903
758
  request_ack,
759
+ display_auth,
760
+ ack_capability,
904
761
  } = body || {}
905
762
  // 連携カード種別 (Phase 2, 2026-07-23): 注入を受け手画面で専用カード表示させるためのタグ。
906
763
  // 既知値 ('peer'|'orchestrator') のみ通し、未知値/未指定は undefined に落とす (未知の source を
@@ -910,6 +767,15 @@ export class SiblingInjectBridge {
910
767
  const isList = op === "list"
911
768
  const isExplicit = op === "inject"
912
769
  const isDelegateEvent = op === "delegate-event"
770
+ const ackV2 = isDelegateEvent && request_ack === true && ack_capability === "persisted-delivered-v2"
771
+ const delegateKind = target_cli_kind === undefined ? "claude" : target_cli_kind
772
+ let displayAuthorized = false
773
+ const hasDisplayAuth = isDelegateEvent && Object.hasOwn(body, "display_auth")
774
+ if (hasDisplayAuth) {
775
+ try { displayAuthorized = verifyDisplayToken(cwd, display_auth) } catch {
776
+ this.logger?.warn?.({}, "delegate display auth unavailable")
777
+ }
778
+ }
913
779
  const isReviewEvent = op === "review-event"
914
780
  const hasRequestAck = Object.prototype.hasOwnProperty.call(body || {}, "request_ack")
915
781
  const isRestart = op === "restart"
@@ -917,6 +783,13 @@ export class SiblingInjectBridge {
917
783
  const badKind = !["claude", "codex", "grok"].includes(target_cli_kind)
918
784
  const delegatePhases = new Set(["started", "completed", "failed", "stopped"])
919
785
  const badDelegateBody =
786
+ !["claude", "codex"].includes(delegateKind) ||
787
+ (target_session_id !== undefined && typeof target_session_id !== "string") ||
788
+ Object.keys(body || {}).some((key) => ![
789
+ "op", "cwd", "target_cli_kind", "token", "target_session_id", "delegation_id", "phase",
790
+ "agent", "description", "background", "prompt", "timestamp", "pid", "pid_started_at",
791
+ "request_ack", "display_auth", "ack_capability",
792
+ ].includes(key)) ||
920
793
  typeof delegation_id !== "string" ||
921
794
  !delegation_id ||
922
795
  delegation_id.length > DELEGATION_ID_MAX_LENGTH ||
@@ -934,7 +807,7 @@ export class SiblingInjectBridge {
934
807
  const badBody = isReviewEvent
935
808
  ? !validReviewEvent(body.event) || typeof target_session_id !== "string" || !target_session_id ||
936
809
  typeof cwd !== "string" || !path.isAbsolute(cwd) ||
937
- Object.keys(body).some((key) => !["op", "token", "cwd", "target_cli_kind", "target_session_id", "event"].includes(key)) ||
810
+ Object.keys(body).some((key) => !["op", "token", "cwd", "target_cli_kind", "target_session_id", "event", "display_auth", "ack_capability"].includes(key)) ||
938
811
  !requestInfo.isFile() || requestInfo.isSymbolicLink() || requestInfo.uid !== process.getuid?.() ||
939
812
  (requestInfo.mode & 0o777) !== 0o600
940
813
  : isDelegateEvent
@@ -985,7 +858,7 @@ export class SiblingInjectBridge {
985
858
  }
986
859
  } else if (!cwd || (!isDelegateEvent && badKind) || badBody) {
987
860
  result = { ok: false, error: "invalid_request" }
988
- } else if (!verifySiblingTokenAny(token)) {
861
+ } else if (hasDisplayAuth ? !displayAuthorized : !verifySiblingTokenAny(token)) {
989
862
  // 2026-07-23 統一再設計 1-2: 認可を「ターゲット cwd 所有」→「いずれかの cwd の有効トークン
990
863
  // 所有 (= 同一ユーザーの spawn 済みセッション)」へ緩和し、クロス cwd 連携を解禁する。
991
864
  // cwd は引き続きターゲット解決のスコープとして使う (誤 cwd 着弾・情報漏洩は解決側で担保)。
@@ -1044,29 +917,51 @@ export class SiblingInjectBridge {
1044
917
  ...(pid_started_at !== undefined ? { pid_started_at } : {}),
1045
918
  timestamp: timestamp || new Date().toISOString(),
1046
919
  }
1047
- // 墓標確認から配送成功時の台帳反映まで await を挟まない同期区間に閉じる。
1048
- // 配送失敗した started は台帳へ残さず、終端後に遅着した started は配送前に拒否する。
1049
- const acceptance = this._delegateEventAcceptance(cwd, target_session_id, event)
1050
- if (acceptance === "duplicate") {
920
+ let acceptance
921
+ let persisted = true
922
+ try {
923
+ acceptance = await this.delegationEvents.record(cwd, target_session_id, event, delegateKind)
924
+ } catch (err) {
925
+ persisted = false
926
+ this.logger?.warn?.({ request_id, error: err?.message }, "delegation persistence failed")
927
+ }
928
+ if (ackV2) {
929
+ persisted = persisted && acceptance !== "full"
930
+ const delivery = acceptance === "stale" ? { status: "not_found" }
931
+ : this._emitDelegation(delegateKind, cwd, target_session_id, event)
932
+ const delivered = delivery.status === "delivered"
933
+ result = !persisted
934
+ ? { ok: false, ack_version: 2, persisted: false, delivered,
935
+ error: acceptance === "full" ? "delegation_store_full" : "delegation_persistence_failed", retryable: true }
936
+ : { ok: true, ack_version: 2, persisted: true, delivered,
937
+ ...(acceptance === "duplicate" ? { duplicate: true } : {}),
938
+ ...(acceptance === "stale" ? { stale: true } : {}),
939
+ ...(!delivered && acceptance !== "stale" && acceptance !== "duplicate"
940
+ ? { delivery_status: delivery.status } : {}) }
941
+ } else if (acceptance === "duplicate") {
1051
942
  // ACK は「過去に受理した」だけでなく、対象 stream が現在も live であることも示す。
1052
943
  // 台帳だけで成功を返すと、宛先消失後の retry を受理済みと誤認させる。
1053
- const live = this._liveClaudeSession(cwd, target_session_id)
1054
- result = live.status === "delivered"
944
+ const live = delegateKind === "claude" ? this._liveClaudeSession(cwd, target_session_id)
945
+ : { status: "delivered" }
946
+ // 保存後に未配送のまま切断した可能性があるため、duplicate も再配送する。
947
+ const delivered = live.status === "delivered"
948
+ ? this._emitDelegation(delegateKind, cwd, target_session_id, event)
949
+ : live
950
+ result = delivered.status === "delivered"
1055
951
  ? { ok: true, duplicate: true }
1056
- : this._delegateTargetFailure(live)
952
+ : this._delegateTargetFailure(delivered)
1057
953
  } else if (acceptance === "stale") {
1058
954
  result = { ok: false, error: "stale_delegate_event" }
955
+ } else if (acceptance === "full") {
956
+ result = { ok: false, error: "delegation_store_full" }
1059
957
  } else {
1060
- const r = this.claudeBridge?.emitToStream
1061
- ? this.claudeBridge.emitToStream(cwd, target_session_id, event)
1062
- : { status: "not_found" }
958
+ const r = this._emitDelegation(delegateKind, cwd, target_session_id, event)
1063
959
  if (r.status === "delivered") {
1064
- // acceptance 直前に prune 済み。同じ同期区間で二重 prune しない。
1065
- this._recordDelegateEvent(cwd, target_session_id, event, { prune: false })
1066
960
  result = { ok: true, delivered_to: r.session }
1067
961
  } else {
1068
- result = this._delegateTargetFailure(r)
962
+ result = { ...this._delegateTargetFailure(r), persisted }
1069
963
  }
964
+ if (!persisted) result.persisted = false
1070
965
  }
1071
966
  }
1072
967
  } else if (isList) {
@@ -1251,6 +1146,10 @@ export class SiblingInjectBridge {
1251
1146
  }
1252
1147
  }
1253
1148
 
1149
+ if (ackV2 && result.ack_version !== 2) {
1150
+ result = { ok: false, ack_version: 2, persisted: false, delivered: false,
1151
+ error: result.error, retryable: false }
1152
+ }
1254
1153
  this.logger?.[result.ok ? "info" : "warn"]?.(
1255
1154
  {
1256
1155
  request_id,
@@ -24,6 +24,8 @@
24
24
  * 範囲がこの cwd 限定トークンより大きくなるため。
25
25
  */
26
26
 
27
+ import { displayTokenForCwd } from "./delegate-display-auth.mjs"
28
+
27
29
  import { randomBytes } from "node:crypto"
28
30
 
29
31
  /** @type {Map<string, string>} cwd -> token */
@@ -126,6 +128,14 @@ export function buildSiblingEnv(cwd, { selfKind, delegateEventAck = false, strip
126
128
  ...(delegateEventAck ? { COCKPIT_DELEGATE_EVENT_ACK: "1" } : {}),
127
129
  COCKPIT_SIBLING_SELF_KIND: selfKind,
128
130
  }
131
+ delete env.COCKPIT_DELEGATE_DISPLAY_TOKEN
132
+ delete env.COCKPIT_DELEGATE_EVENT_ACK_VERSION
133
+ try {
134
+ env.COCKPIT_DELEGATE_DISPLAY_TOKEN = displayTokenForCwd(cwd)
135
+ env.COCKPIT_DELEGATE_EVENT_ACK_VERSION = "2"
136
+ } catch {
137
+ console.warn("delegate display auth unavailable; display capability omitted")
138
+ }
129
139
  // ACK を付けない CLI では、継承元に ACK があっても子へ持ち込まない。
130
140
  if (!delegateEventAck) delete env.COCKPIT_DELEGATE_EVENT_ACK
131
141
  if (stripParentSessionIds) {
@@ -0,0 +1,7 @@
1
+ export function truncateString(value, maxLength, truncationNote) {
2
+ if (value.length <= maxLength) return value
3
+ let end = maxLength
4
+ const lastCodeUnit = value.charCodeAt(end - 1)
5
+ if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) end -= 1
6
+ return value.slice(0, end) + truncationNote(value.length)
7
+ }