@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.
- package/.env.example +37 -0
- package/CHANGELOG.md +587 -0
- package/README.md +3 -1
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +12 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +155 -30
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +27 -14
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +43 -1
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +536 -0
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +31 -5
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +83 -0
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +25 -1
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +640 -85
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +29 -1
- package/server/routes/agent-session-stream.ts +23 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +46 -1
- package/server/routes/live-events.ts +334 -0
- package/server/routes/permission-broker.ts +95 -5
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +223 -12
- package/server/routes/today-brief.ts +134 -44
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +28 -4
- package/server/routes/work-board.ts +9 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +76 -1
- package/server/web-control/app.js +2247 -98
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/control.css +25 -0
- package/server/web-control/index.html +21 -3
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +94 -9
- package/server/web-control/panes/cos-chat.js +628 -44
- package/server/web-control/panes/meetings.css +18 -2
- package/server/web-control/panes/meetings.js +24 -8
- package/server/web-control/panes/memories.css +17 -2
- package/server/web-control/panes/memories.js +127 -42
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.js +162 -21
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +32 -3
- package/server/web-control/panes/settings.js +97 -6
- package/server/web-control/panes/today-actions.css +46 -0
- package/server/web-control/panes/today-actions.js +119 -17
- package/server/web-control/panes/work.css +13 -0
- package/server/web-control/panes/work.js +188 -37
- package/server/web-control/shell.js +17 -14
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- 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
|
-
|
|
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
|
+
}
|