@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * "Show files and images your sessions make" (6.99.35). Miles asked where a person opts in to the images (6.99.28) and
3
+ * files (SendUserFile) Control Web shows from a session's project folder. This is that switch, stored the way the
4
+ * Today summary switch is (lib/today-brief.ts): a small private JSON file in the data folder, written atomically.
5
+ *
6
+ * - DEFAULT ON, as both features shipped.
7
+ * - The Settings toggle (Control Web, Settings, Privacy) saves { schema: 1, enabled } to web-session-files-settings.json.
8
+ * - COS_WEB_SESSION_FILES=0 (or false, off) on the Mac forces it OFF, whatever was saved. No value forces it on: an
9
+ * environment that wants previews leaves the variable unset.
10
+ * - Read on every request, so a change applies at once and a restart is never needed.
11
+ * - QA 7.0.0: only a file that is not there (first use) gets the default. A saved file that is damaged, unreadable or
12
+ * not this schema fails CLOSED (source 'damaged', off): it may have said off, and a privacy switch never turns itself
13
+ * back on. The Settings row says so, and saving from it writes a good file again.
14
+ *
15
+ * Preview engine phase 1: link cards (outputLinks) and GET /api/preview/... ride the same switch, default on.
16
+ *
17
+ * Off means: GET .../output-images/:id/content, .../output-files/:id/content and .../tool-images/:id/content answer 403 previews_off before any
18
+ * transcript or file is read, and the session detail's turns carry `previewsOff: true` instead of image and file
19
+ * descriptors (withoutPreviews), so not even a name reaches the browser.
20
+ */
21
+ import { readFileSync } from 'node:fs'
22
+ import { dirname } from 'node:path'
23
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
24
+ import { dataPath } from './data-dir.js'
25
+ import { readSwitch } from './env-switch.js'
26
+ import { securePrivateDirectory } from './secure-user-config.js'
27
+ import type { SessionTurn } from './agent-session-turns.js'
28
+ import { withoutLinkCards } from './session-links.js'
29
+
30
+ export const SESSION_FILES_SETTINGS_FILE = 'web-session-files-settings.json'
31
+ export type SessionFilesSource = 'env' | 'settings' | 'default' | 'damaged'
32
+ export interface SessionFilesSetting { enabled: boolean; source: SessionFilesSource; saved: boolean | null }
33
+ export interface SessionFilesSettingStore {
34
+ read(): SessionFilesSetting
35
+ set(enabled: boolean): SessionFilesSetting
36
+ }
37
+
38
+ export function createSessionFilesSetting(deps: { file: () => string; env?: () => Record<string, string | undefined> }): SessionFilesSettingStore {
39
+ const env = deps.env ?? (() => process.env)
40
+ // 'absent' only for ENOENT (first use). Any other read error, bad JSON or a foreign shape is 'damaged'.
41
+ type Saved = { kind: 'absent' } | { kind: 'damaged' } | { kind: 'saved'; enabled: boolean }
42
+ const saved = (): Saved => {
43
+ let text: string
44
+ try { text = readFileSync(deps.file(), 'utf8') } catch (e) {
45
+ return (e as NodeJS.ErrnoException | null)?.code === 'ENOENT' ? { kind: 'absent' } : { kind: 'damaged' }
46
+ }
47
+ try {
48
+ const raw = JSON.parse(text) as unknown
49
+ if (raw && typeof raw === 'object' && (raw as { schema?: unknown }).schema === 1 && typeof (raw as { enabled?: unknown }).enabled === 'boolean') {
50
+ return { kind: 'saved', enabled: (raw as { enabled: boolean }).enabled }
51
+ }
52
+ } catch { /* damaged */ }
53
+ return { kind: 'damaged' }
54
+ }
55
+ const store: SessionFilesSettingStore = {
56
+ read() {
57
+ const s = saved()
58
+ const value = s.kind === 'saved' ? s.enabled : null
59
+ // Only an off value forces anything; readSwitch with an ON fallback is false exactly for 0, false and off.
60
+ if (!readSwitch(env().COS_WEB_SESSION_FILES, true)) return { enabled: false, source: 'env', saved: value }
61
+ if (s.kind === 'saved') return { enabled: s.enabled, source: 'settings', saved: s.enabled }
62
+ if (s.kind === 'damaged') return { enabled: false, source: 'damaged', saved: null }
63
+ return { enabled: true, source: 'default', saved: null }
64
+ },
65
+ set(enabled) {
66
+ const file = deps.file()
67
+ securePrivateDirectory(dirname(file))
68
+ durableAtomicWriteFileSync(file, JSON.stringify({ schema: 1, enabled }) + '\n', { mode: 0o600 })
69
+ return store.read()
70
+ },
71
+ }
72
+ return store
73
+ }
74
+
75
+ /** The server's switch, in the data folder. */
76
+ export const sessionFilesSetting = createSessionFilesSetting({ file: () => dataPath(SESSION_FILES_SETTINGS_FILE) })
77
+
78
+ /** Turns with every image and file descriptor (tool-result and prompt images too) removed and `previewsOff` marked where there were any. Pure. */
79
+ export function withoutPreviews(turns: SessionTurn[]): SessionTurn[] {
80
+ return turns.map(t => {
81
+ if (!t.outputImages?.length && !t.outputFiles?.length && !t.toolImages?.length && !t.toolImagesMore && !t.outputLinks?.length) return t
82
+ const { outputImages: _i, outputFiles: _f, toolImages: _t, toolImagesMore: _m, outputLinks: _l, ...rest } = t
83
+ // Preview engine phase 1: link cards ride this switch. Off, a card marker turns back into the link's words and the
84
+ // "N more linked files" sentence goes (QA 7.0.5), so not even a count reaches the browser. The Mac path was already
85
+ // gone from the text (session-links.ts).
86
+ return { ...rest, ...(_l?.length ? { text: withoutLinkCards(rest.text) } : {}), previewsOff: true as const }
87
+ })
88
+ }
@@ -13,6 +13,8 @@
13
13
  // interactive-only events (Notification, StopFailure, sub-agents, compaction, model switch)
14
14
  // whose shapes are still the documented ones until a Desktop recording lands.
15
15
 
16
+ import { questionRowText, waitingQuestionWordsEnabled } from './full-hold.js'
17
+ import { captureTurnAsk, turnEndAsksEnabled, WRAP_UP_TOOL } from './turn-end-ask.js'
16
18
  import { createHash } from 'node:crypto'
17
19
  import { profileIdForCodexPath } from './codex-homes.js'
18
20
  import { isKeyProfileId } from '../../shared/key-preference.js'
@@ -208,12 +210,31 @@ function canonicalJson(value: unknown): string {
208
210
  export function toolTarget(toolInput: unknown, max = 80): string {
209
211
  if (!toolInput || typeof toolInput !== 'object') return ''
210
212
  const i = toolInput as Record<string, unknown>
211
- const candidate = [i.command, i.file_path, i.path, i.url, i.pattern, i.query, i.prompt, i.description]
213
+ // X1: an AskUserQuestion carries only `questions`. With COS_WAITING_QUESTION_WORDS on (default ON, Miles
214
+ // 22:10) its target is its first question's text, control and format characters out, cut at `max` (80)
215
+ // below, so a row waiting on a question says what it asks. Never an option, a description or an answer.
216
+ // `0`: a question's target is '' exactly as in 7.0.13, and the words reach neither the ledger nor a row.
217
+ const firstQuestion = waitingQuestionWordsEnabled(process.env) && Array.isArray(i.questions) && i.questions.length > 0 && i.questions[0] && typeof i.questions[0] === 'object'
218
+ ? questionRowText((i.questions[0] as Record<string, unknown>).question)
219
+ : undefined
220
+ const candidate = [i.command, i.file_path, i.path, i.url, i.pattern, i.query, i.prompt, i.description, firstQuestion]
212
221
  .find(v => typeof v === 'string' && v.trim().length > 0)
213
222
  const text = typeof candidate === 'string' ? candidate.replace(/\s+/g, ' ').trim() : ''
214
223
  return text.length > max ? text.slice(0, max - 1) + '…' : text
215
224
  }
216
225
 
226
+ /**
227
+ * 7.0.18 (X1): a turn-end ask (lib/turn-end-ask.ts) kept in the ledger so a restart replays it. Only on a
228
+ * PostToolUse of `mcp__ccd_turn__wrap_up`, only with COS_TURN_END_ASKS on, and only the bounded capture:
229
+ * the ask (80), at most 2 reply labels (40) and `blocked`. Never `recap`, never `tool_response`. An
230
+ * additive key, so 7.0.17 replays the row and ignores it. `tool_target` stays as 7.0.17 computes it.
231
+ */
232
+ function turnAskProjection(event: HookEventName, toolName: string, toolInput: unknown): Record<string, unknown> {
233
+ if (event !== 'PostToolUse' || toolName !== WRAP_UP_TOOL || !turnEndAsksEnabled()) return {}
234
+ const capture = captureTurnAsk(toolInput, waitingQuestionWordsEnabled(process.env))
235
+ return capture ? { turn_ask: capture } : {}
236
+ }
237
+
217
238
  /** Text fields kept from a payload. Everything else is dropped before the ledger sees it. */
218
239
  export const HOOK_TEXT_MAX = 400
219
240
 
@@ -272,6 +293,7 @@ export function projectHookPayload(event: HookEventName, payload: Record<string,
272
293
  ...(event === 'PermissionRequest' && Array.isArray(payload.permission_suggestions)
273
294
  ? { permission_suggestions: payload.permission_suggestions.slice(0, 4) }
274
295
  : {}),
296
+ ...turnAskProjection(event, toolName, payload.tool_input),
275
297
  }
276
298
  }
277
299
  case 'Notification':
@@ -21,6 +21,9 @@ import { HALT_MARKER_TTL_MS, HALT_REARM_UNVERIFIED_MS, clearHaltMarkerOnSessionE
21
21
  import { cancelVoidsOpenTurn, threadCancel } from './session-cancel.js'
22
22
  import { ensureHookRuntimeFiles, ensureStableHookScript, hookSpoolDir, hookStatus, type HookStatus } from './claude-hooks-installer.js'
23
23
  import { codexHooksHealthField, cursorObserverHealthField, type CodexHooksHealth, type CursorObserverHealth } from './provider-observe.js'
24
+ import { readHidIdleSeconds } from './permission-broker.js'
25
+ import { waitingQuestionWordsEnabled } from './full-hold.js'
26
+ import { TurnEndAskTracker, turnEndAsksEnabled, type TurnEndAskCounters } from './turn-end-ask.js'
24
27
 
25
28
  export function sessionHooksEnabled(): boolean {
26
29
  const raw = process.env.COS_SESSION_HOOKS
@@ -256,6 +259,8 @@ export function haltHandedOffTurn(sessionId: string, marker: HaltMarker, turn: {
256
259
  let ledger: SessionHookLedger | null = null
257
260
  let ingester: SpoolIngester | null = null
258
261
  let replayed: { rows: number; applied: number } | null = null
262
+ /** 7.0.18 (X1): the turn-end ask tracker, only while COS_TURN_END_ASKS is on (read once at startup). */
263
+ let turnEndAskTracker: TurnEndAskTracker | null = null
259
264
  /** The two-scan memory: consecutive dead observations and when the first one was. */
260
265
  const deadById = new Map<string, { scans: number; since: number | null; at: number }>()
261
266
  const DEAD_MEMORY_MS = 60 * 60_000
@@ -271,6 +276,18 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
271
276
  ledger = new SessionHookLedger(dataPath('session-hook-events.jsonl'))
272
277
  const enabled = sessionHooksEnabled()
273
278
  const since = Date.now() - LEDGER_REPLAY_WINDOW_MS
279
+ // 7.0.18 (X1): subscribed BEFORE the replay, so an ask asked before a restart is still known after it.
280
+ // Off (COS_TURN_END_ASKS=0): no tracker, no subscription, no desk reads, every row 7.0.17's.
281
+ turnEndAskTracker?.stop()
282
+ turnEndAskTracker = enabled && turnEndAsksEnabled() ? new TurnEndAskTracker({
283
+ now: () => Date.now(),
284
+ enabled: () => true,
285
+ words: () => waitingQuestionWordsEnabled(process.env),
286
+ readDeskIdleSeconds: readHidIdleSeconds,
287
+ deskIdleSeconds,
288
+ }) : null
289
+ const tracker = turnEndAskTracker
290
+ const unsubscribeTurnAsk = tracker ? sessionSignalStore.subscribe((signal, env, child) => tracker.observe(env, child, signal)) : () => {}
274
291
  const replay = ledger.replay(since, (env, _key, child, entrypoint) => {
275
292
  if (!enabled) return
276
293
  sessionSignalStore.apply(env, child)
@@ -321,6 +338,8 @@ export function startSessionHooksRuntime(options: { port: number }): SessionHook
321
338
  stop() {
322
339
  clearInterval(pruneTimer)
323
340
  unsubscribeHalt()
341
+ unsubscribeTurnAsk()
342
+ tracker?.stop()
324
343
  ingester?.stop()
325
344
  },
326
345
  }
@@ -472,8 +491,10 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
472
491
  prevDeadScans: prev?.scans ?? 0,
473
492
  prevDeadSince: prev?.since ?? null,
474
493
  attachedTurn: input.attachedTurn === true,
494
+ ...(turnEndAskTracker ? { turnEndAsk: turnEndAskTracker.visibleAsk(key, now) } : {}),
475
495
  })
476
496
  if (derived.agent_state === 'ended') {
497
+ turnEndAskTracker?.sessionRowEnded(key)
477
498
  for (const listener of rowEndedListeners) {
478
499
  try { listener(key) } catch { /* a listener never breaks a row */ }
479
500
  }
@@ -489,7 +510,20 @@ export function deriveForRow(input: { sessionId: string; registry?: RegistryFact
489
510
  return derived
490
511
  }
491
512
 
513
+ /** 7.0.18 (X1): the turn-end ask counters for the authenticated stats, or null with the switch off. */
514
+ export function turnEndAskStats(): (TurnEndAskCounters & { tracked: number }) | null {
515
+ return turnEndAskTracker ? turnEndAskTracker.stats() : null
516
+ }
517
+
518
+ /** Tests only: the tracker the runtime would build, installed directly. */
519
+ export function __setTurnEndAskTrackerForTests(tracker: TurnEndAskTracker | null): void {
520
+ turnEndAskTracker?.stop()
521
+ turnEndAskTracker = tracker
522
+ }
523
+
492
524
  export function __resetSessionHooksForTests(): void {
525
+ turnEndAskTracker?.stop()
526
+ turnEndAskTracker = null
493
527
  rowEndedListeners.clear()
494
528
  sessionSignalStore.__resetForTests()
495
529
  deadById.clear()
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Link cards (preview engine phase 1, PLAN_preview_engine_2026-11-08 4f). A Markdown link in a reply to a local file
3
+ * becomes a card where it sits. 40% of sessions link a local file; 3,389 links in 14 days, none of which worked.
4
+ *
5
+ * This module is pure apart from filesystem reads in `resolveLinkTarget`:
6
+ * - `linkTokens`: the same bounded scanner as the inline-image tokens, for `[label](target)`. Code fences, inline
7
+ * code, indented blocks and comments stay inert. Web URLs and #anchors are not local and stay as written.
8
+ * - `splitLineAnchor`: `file.ts:42`, `:42:7`, `:42-50` and `#L42` become `line`, never part of the name.
9
+ * - `resolveLinkTarget`: the row's cwd first, then the workspace root (21% of relative links need the root), then
10
+ * authorized only inside the workspace or a Settings extra folder, and never a denied secret name.
11
+ * - `previewKind`: what the viewer does with a name.
12
+ *
13
+ * Paths never leave the server: a card carries a base name, the link's words and an opaque id.
14
+ */
15
+ import { realpathSync, statSync } from 'node:fs'
16
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { isDeniedSecretPath } from './secret-path-guard.js'
19
+
20
+ export const LINK_CARDS_PER_TURN = 20
21
+ export const SESSION_OUTPUT_LINK_ID = /^al_[a-f0-9]{64}$/
22
+ /** The marker a card leaves in the projected text: `[label](cos-file:N)`, N the index into the turn's outputLinks. */
23
+ export const LINK_MARKER = /\[([^\]\n]*)\]\(cos-file:(\d{1,2})\)/g
24
+
25
+ export type PreviewKind = 'image' | 'pdf' | 'html' | 'markdown' | 'text' | 'code' | 'table' | 'document' | 'slides' | 'sheet'
26
+ | 'audio' | 'video' | 'archive' | 'folder' | 'file'
27
+ /** `secret_file`: the 7.0.4 shared deny list (secret-path-guard.ts) refused the name, as written or canonically. */
28
+ export type LinkReason = 'outside_workspace' | 'missing' | 'unavailable' | 'secret_file'
29
+
30
+ /** A link card. Only a base name, the link's words and an opaque id travel; never a Mac path. */
31
+ export interface SessionOutputLink {
32
+ id: string
33
+ label: string
34
+ name: string
35
+ kind: PreviewKind
36
+ type: string
37
+ size?: number
38
+ line?: number
39
+ state: 'available' | 'unavailable'
40
+ reason?: LinkReason
41
+ /** The words the card shows instead (SECRET_FILE_NOTICE), set only with `secret_file`. */
42
+ notice?: string
43
+ }
44
+
45
+ export interface LinkToken { label: string; target: string; start: number; end: number }
46
+
47
+ const KINDS: Array<[RegExp, PreviewKind, string]> = [
48
+ [/\.(png|jpe?g|webp|hei[cf]|avif)$/i, 'image', 'Image'],
49
+ [/\.gif$/i, 'image', 'GIF image'],
50
+ [/\.pdf$/i, 'pdf', 'PDF'],
51
+ [/\.html?$/i, 'html', 'HTML page'],
52
+ [/\.(md|markdown|mdx)$/i, 'markdown', 'Markdown'],
53
+ [/\.csv$/i, 'table', 'CSV'],
54
+ [/\.tsv$/i, 'table', 'TSV'],
55
+ [/\.(txt|log|text)$/i, 'text', 'Text'],
56
+ [/\.json$/i, 'code', 'JSON'],
57
+ [/\.jsonl$/i, 'code', 'JSON lines'],
58
+ [/\.ya?ml$/i, 'code', 'YAML'],
59
+ [/\.toml$/i, 'code', 'TOML'],
60
+ [/\.xml$/i, 'code', 'XML'],
61
+ // An SVG can carry script, so it is shown as its source, never as a picture.
62
+ [/\.svg$/i, 'code', 'SVG source'],
63
+ [/\.(ts|tsx|mts|cts)$/i, 'code', 'TypeScript'],
64
+ [/\.(js|jsx|mjs|cjs)$/i, 'code', 'JavaScript'],
65
+ [/\.py$/i, 'code', 'Python'],
66
+ [/\.swift$/i, 'code', 'Swift'],
67
+ [/\.(sh|zsh|bash)$/i, 'code', 'Shell script'],
68
+ [/\.(css|scss)$/i, 'code', 'CSS'],
69
+ [/\.(rb|go|rs|java|kt|c|h|cc|cpp|hpp|m|mm|sql|lua|php)$/i, 'code', 'Source code'],
70
+ [/\.(plist|ini|conf|cfg|properties)$/i, 'code', 'Settings file'],
71
+ [/\.docx?$/i, 'document', 'Word document'],
72
+ [/\.(pages|rtf)$/i, 'document', 'Document'],
73
+ [/\.xlsx?$/i, 'sheet', 'Excel workbook'],
74
+ [/\.numbers$/i, 'sheet', 'Spreadsheet'],
75
+ [/\.pptx?$/i, 'slides', 'PowerPoint deck'],
76
+ [/\.key$/i, 'slides', 'Keynote deck'],
77
+ [/\.(wav|mp3|m4a|aac|flac|aiff?)$/i, 'audio', 'Audio'],
78
+ [/\.(mp4|mov|webm|m4v)$/i, 'video', 'Video'],
79
+ [/\.(zip|tar|gz|tgz|bz2|xz|7z)$/i, 'archive', 'Archive'],
80
+ [/\.ehpk$/i, 'archive', 'EHPK package'],
81
+ ]
82
+ export function previewKind(name: string): { kind: PreviewKind; type: string } {
83
+ for (const [re, kind, type] of KINDS) if (re.test(name)) return { kind, type }
84
+ const ext = /\.([a-z0-9]{1,8})$/i.exec(name)
85
+ return { kind: 'file', type: ext ? ext[1].toUpperCase() + ' file' : 'File' }
86
+ }
87
+
88
+ /** The base name a person sees: no directories, no control characters, at most 120 characters. */
89
+ export function baseName(source: string): string {
90
+ const base = source.split(/[\\/]/).filter(Boolean).pop() || ''
91
+ const clean = base.replace(/[\u0000-\u001f\u007f]/g, '').trim()
92
+ return (clean || 'File').slice(0, 120)
93
+ }
94
+
95
+ /**
96
+ * A link's words, made safe to show: a label that is itself an absolute or home path becomes its base name, so
97
+ * `[/Users/x/plan.md](/Users/x/plan.md)` never puts the path in front of anyone. Anything else is kept (120 chars).
98
+ */
99
+ export function linkLabel(label: string, name: string): string {
100
+ const clean = label.replace(/[\u0000-\u001f\u007f]/g, ' ').trim()
101
+ if (!clean) return name
102
+ if (/^(?:file:\/\/|~\/|\/)/.test(clean) || /(?:^|[\s(`'"])(?:\/Users\/|\/private\/|\/Volumes\/|\/var\/folders\/)/.test(clean)) return baseName(clean.replace(/^file:\/\//, ''))
103
+ return clean.slice(0, 120)
104
+ }
105
+
106
+ /**
107
+ * The words a link card shows in the reply. A label that is the file's own name keeps the line the link points at,
108
+ * so `[app.ts](/Users/x/app.ts#L42)` reads `app.ts:42`, as the 7.0.4 redactor wrote it. Prose labels stay as written.
109
+ */
110
+ export function linkCardWords(label: string, name: string, target: string): string {
111
+ const words = linkLabel(label, name)
112
+ const { path, line } = splitLineAnchor(target)
113
+ if (!line) return words
114
+ const own = baseName(path.replace(/^file:\/\//i, ''))
115
+ return words === own || words === name ? words + ':' + line : words
116
+ }
117
+
118
+ export const LINK_SCAN_CHARS = 128 * 1024
119
+
120
+ /**
121
+ * `[label](target)` tokens in reply text, never `![..](..)` (images have their own scanner). Bounded like the image
122
+ * scanner: inert inside fences, inline code, indented blocks and comments; the label has no brackets or newline; the
123
+ * target is angle-wrapped (spaces allowed) or balanced parentheses on one line. A trailing ` "title"` is dropped.
124
+ */
125
+ export function linkTokens(raw: string, scanChars = LINK_SCAN_CHARS): LinkToken[] {
126
+ const text = raw.slice(0, scanChars)
127
+ const out: LinkToken[] = []
128
+ let fence = '', inline = 0
129
+ for (let i = 0; i < text.length; i++) {
130
+ if (i === 0 || text[i - 1] === '\n') {
131
+ if (!fence && /^(?: {4}| {0,3}\t)/.test(text.slice(i, i + 4))) { i = text.indexOf('\n', i); if (i < 0) break; continue }
132
+ const m = /^( {0,3})(`{3,}|~{3,})/.exec(text.slice(i, i + 80))
133
+ if (m) { if (!fence) fence = m[2]; else if (m[2][0] === fence[0] && m[2].length >= fence.length) fence = ''; i = text.indexOf('\n', i); if (i < 0) break; continue }
134
+ }
135
+ if (fence) continue
136
+ if (text[i] === '\\') { i++; continue }
137
+ if (text[i] === '`') { let n = 1; while (text[i + n] === '`') n++; if (!inline) inline = n; else if (inline === n) inline = 0; i += n - 1; continue }
138
+ if (inline) continue
139
+ if (text.startsWith('<!--', i)) { const end = text.indexOf('-->', i + 4); if (end < 0) break; i = end + 2; continue }
140
+ if (text[i] !== '[' || text[i - 1] === '!') continue
141
+ const close = text.indexOf(']', i + 1)
142
+ if (close < 0 || close - i > 250 || text[close + 1] !== '(') continue
143
+ const label = text.slice(i + 1, close)
144
+ if (label.includes('[') || label.includes('\n')) continue
145
+ let j = close + 2, target = '', valid = false
146
+ if (text[j] === '<') {
147
+ const end = text.indexOf('>', j + 1)
148
+ if (end >= 0 && text[end + 1] === ')' && !text.slice(j + 1, end).includes('\n')) { target = text.slice(j + 1, end); j = end + 2; valid = true }
149
+ } else {
150
+ const start = j; let depth = 1
151
+ while (j < text.length && j - start <= 4096) {
152
+ if (text[j] === '\n') break
153
+ if (text[j] === '(') depth++
154
+ if (text[j] === ')' && --depth === 0) { target = text.slice(start, j); j++; valid = true; break }
155
+ j++
156
+ }
157
+ }
158
+ if (!valid || !target || target.length > 4096 || /[\u0000-\u001f\u007f]/.test(target)) continue
159
+ const titled = /^(\S+)\s+"[^"]*"$/.exec(target)
160
+ if (titled) target = titled[1]
161
+ out.push({ label, target, start: i, end: j }); i = j - 1
162
+ }
163
+ return out
164
+ }
165
+
166
+ /** A target that names a local file: a path, `~/...` or `file://`. Web URLs, mail, anchors and network paths are not. */
167
+ export function isLocalTarget(target: string): boolean {
168
+ if (!target || target.startsWith('#') || target.startsWith('//') || target.includes('\\')) return false
169
+ if (/^file:\/\//i.test(target)) return true
170
+ // `server.ts:42` is a path with a line, not a scheme.
171
+ return !/^[a-z][a-z0-9+.-]*:/i.test(splitLineAnchor(target).path)
172
+ }
173
+
174
+ /** `x.ts:42`, `x.ts:42:7`, `x.ts:42-50`, `x.ts#L42`, `x.ts#L42-L50` → the path and line 42. */
175
+ export function splitLineAnchor(target: string): { path: string; line?: number } {
176
+ let m = /^(.*?)#L(\d{1,7})(?:C\d+)?(?:-L?\d{1,7})?$/.exec(target)
177
+ if (m && m[1]) return { path: m[1], line: Number(m[2]) || undefined }
178
+ m = /^(.*?):(\d{1,7})(?::\d{1,5}|-\d{1,7})?$/.exec(target)
179
+ if (m && m[1] && !/:$/.test(m[1])) return { path: m[1], line: Number(m[2]) || undefined }
180
+ const hash = target.indexOf('#')
181
+ return { path: hash > 0 ? target.slice(0, hash) : target }
182
+ }
183
+
184
+ export interface LinkRoots {
185
+ /** The session's canonical workspace, or null (broad or unknown). */
186
+ workspace: string | null
187
+ /** Canonical Settings extra folders. */
188
+ extra: readonly string[]
189
+ home: string
190
+ }
191
+ export type Resolved =
192
+ | { state: 'available'; path: string; root: string; name: string; isDir: boolean; size?: number }
193
+ | { state: 'unavailable'; reason: LinkReason; name: string }
194
+
195
+ export function insideRoot(root: string, path: string): boolean {
196
+ const rel = relative(root, path)
197
+ return rel !== '' && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)
198
+ }
199
+ /** The allowed root holding a canonical path, or null. */
200
+ export function authorizedRoot(path: string, roots: LinkRoots): string | null {
201
+ if (roots.workspace && insideRoot(roots.workspace, path)) return roots.workspace
202
+ for (const r of roots.extra) if (insideRoot(r, path)) return r
203
+ return null
204
+ }
205
+
206
+ function expandTarget(target: string, home: string): string | null {
207
+ if (/^file:\/\//i.test(target)) { try { return fileURLToPath(target) } catch { return null } }
208
+ if (target === '~') return home
209
+ if (target.startsWith('~/')) return join(home, target.slice(2))
210
+ return target
211
+ }
212
+
213
+ /**
214
+ * Where a link points, and whether COS may show it. Bases: the row's cwd, then the workspace root. A relative link
215
+ * that exists under neither is missing (when it would sit inside an allowed root) or outside. The canonical file
216
+ * decides containment, so a link out of the root is outside. A denied secret name is refused before any read, by its
217
+ * written name and by its canonical name.
218
+ */
219
+ export function resolveLinkTarget(rawTarget: string, bases: readonly (string | null | undefined)[], roots: LinkRoots): Resolved {
220
+ const { path: written } = splitLineAnchor(rawTarget)
221
+ const variants = [written]
222
+ if (/%[0-9a-f]{2}/i.test(written)) { try { const d = decodeURIComponent(written); if (d !== written) variants.push(d) } catch { /* keep as written */ } }
223
+ // A percent-encoded name reads as its words ("Q3 plan.md", not "Q3%20plan.md").
224
+ const name = baseName(variants[variants.length - 1])
225
+ const lexicals: string[] = []
226
+ for (const v of variants) {
227
+ const expanded = expandTarget(v, roots.home)
228
+ if (!expanded) continue
229
+ if (isAbsolute(expanded)) lexicals.push(resolve(expanded))
230
+ else for (const b of bases) if (b && isAbsolute(b)) lexicals.push(resolve(b, expanded))
231
+ }
232
+ if (!lexicals.length) return { state: 'unavailable', reason: 'outside_workspace', name }
233
+ if (lexicals.some(p => isDeniedSecretPath(p))) return { state: 'unavailable', reason: 'secret_file', name }
234
+ for (const lexical of lexicals) {
235
+ let canonical: string
236
+ try { canonical = realpathSync(lexical) } catch { continue }
237
+ const root = authorizedRoot(canonical, roots)
238
+ if (!root) return { state: 'unavailable', reason: 'outside_workspace', name }
239
+ if (isDeniedSecretPath(canonical)) return { state: 'unavailable', reason: 'secret_file', name }
240
+ try {
241
+ const st = statSync(canonical)
242
+ if (st.isDirectory()) return { state: 'available', path: canonical, root, name, isDir: true }
243
+ if (!st.isFile()) return { state: 'unavailable', reason: 'missing', name }
244
+ // 7.0.4 QA's sent-file rule: a file with another hard link may be a secret under this name. Pictures are exempt
245
+ // (they are normalised to a JPEG, never served as they are).
246
+ if (st.nlink > 1 && !/\.(png|jpe?g|webp|hei[cf]|avif)$/i.test(canonical)) return { state: 'unavailable', reason: 'secret_file', name }
247
+ return { state: 'available', path: canonical, root, name, isDir: false, size: st.size }
248
+ } catch { return { state: 'unavailable', reason: 'missing', name } }
249
+ }
250
+ // Nothing there. Say missing only for a path that would be inside an allowed root (by its real parent folder).
251
+ const wouldBeInside = lexicals.some(p => {
252
+ if (authorizedRoot(p, roots)) return true
253
+ try { return authorizedRoot(join(realpathSync(resolve(p, '..')), baseName(p)), roots) !== null } catch { return false }
254
+ })
255
+ return { state: 'unavailable', reason: wouldBeInside ? 'missing' : 'outside_workspace', name }
256
+ }
257
+
258
+ /** Text with every card marker turned back into its words. Used when previews are off. Pure. */
259
+ export function withoutLinkMarkers(text: string): string {
260
+ return text.replace(LINK_MARKER, '$1')
261
+ }
262
+
263
+ /** The sentence a reply ends with when it links more files than LINK_CARDS_PER_TURN (added after its last word). */
264
+ export function linkOverflowSentence(extra: number): string {
265
+ return extra + (extra === 1 ? ' more linked file is not shown as a card.' : ' more linked files are not shown as cards.')
266
+ }
267
+ const LINK_OVERFLOW = /\n\n\d{1,6} more linked files? (?:is|are) not shown as (?:a )?cards?\./g
268
+ /** The reply cap can cut the sentence: `\n\n23 more linked fi…`, at the end or just before the scan-limit line. */
269
+ const LINK_OVERFLOW_CUT = /\n\n(\d{1,6})([^\n…]*)…(?=\n\[Reply shortened at the image preview scan limit\.\]$|$)/
270
+
271
+ /**
272
+ * QA 7.0.5 (agent 4 C-N1): previews off sends no count, so the overflow sentence goes as well as the markers. A cut
273
+ * sentence goes only when what is left is exactly the start of that sentence, so a reply's own words stay. Pure.
274
+ */
275
+ export function withoutLinkCards(text: string): string {
276
+ const out = withoutLinkMarkers(text).replace(LINK_OVERFLOW, '')
277
+ const cut = LINK_OVERFLOW_CUT.exec(out)
278
+ if (!cut || !linkOverflowSentence(Number(cut[1])).startsWith(cut[1] + cut[2])) return out
279
+ return out.slice(0, cut.index) + out.slice(cut.index + cut[0].length)
280
+ }