@gotcos/glasses-server 7.0.18 → 7.0.29
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/CHANGELOG.md +198 -0
- package/docs/web-key-verifier-contract.md +1 -1
- package/package.json +1 -1
- package/server/index.ts +32 -6
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-store.ts +99 -17
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-stream-events.ts +14 -2
- package/server/lib/tailnet-self-name.ts +57 -10
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/web-key-signer-app.ts +25 -6
- package/server/lib/web-key-signer-tls.ts +790 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/health.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/thread-turn-queue.ts +34 -5
- package/server/routes/web-key.ts +31 -2
- package/server/web-control/.manifest.json +7 -7
- package/server/web-control/app.css +63 -0
- package/server/web-control/app.js +1047 -90
- package/server/web-control/index.html +83 -20
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/today-actions.js +46 -0
- package/server/web-control/panes/work.js +7 -3
- package/server/web-control/theme.js +200 -0
|
@@ -91,6 +91,35 @@ import { resetPreviewLogThrottle, warnPreviewThrottled } from '../lib/preview-lo
|
|
|
91
91
|
import { sessionFilesSetting, withoutPreviews } from '../lib/session-files-setting.js'
|
|
92
92
|
import { withoutLinkMarkers } from '../lib/session-links.js'
|
|
93
93
|
import { createSessionPreviewRouter } from './session-preview.js'
|
|
94
|
+
import { organizerKey, sessionOrganizer, type OrganizerRowFields } from '../lib/session-organizer.js'
|
|
95
|
+
import { listOlderSessions, shortRowFor, OLDER_DEFAULT_DAYS, OLDER_DEFAULT_LIMIT, OLDER_MAX_DAYS, OLDER_MAX_LIMIT, OLDER_MIN_DAYS } from '../lib/agent-session-older.js'
|
|
96
|
+
import { cachedListModel, warmListModels } from '../lib/list-model-memo.js'
|
|
97
|
+
import { cosStartedSessionIds } from '../lib/cos-started-sessions.js'
|
|
98
|
+
|
|
99
|
+
/** Step 2a: the organizer, or null when it is unavailable or empty (the list is then exactly today's). */
|
|
100
|
+
function organizerForList(): ReturnType<typeof sessionOrganizer> | null {
|
|
101
|
+
try {
|
|
102
|
+
const o = sessionOrganizer()
|
|
103
|
+
return o.isAvailable() && !o.isEmpty() ? o : null
|
|
104
|
+
} catch {
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Claude Desktop ids per CLI id, from the request's alias table (an entry written under a Desktop id still matches). */
|
|
109
|
+
function desktopIdsByCli(aliases: ClaudeAliasMap | undefined): Map<string, string[]> {
|
|
110
|
+
const out = new Map<string, string[]>()
|
|
111
|
+
for (const [desktopId, head] of aliases ?? new Map()) {
|
|
112
|
+
const cli = head?.cliSessionId
|
|
113
|
+
if (!cli || cli.startsWith('!')) continue
|
|
114
|
+
out.set(cli, [...(out.get(cli) ?? []), desktopId])
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
function organizerFieldsOf(o: ReturnType<typeof sessionOrganizer> | null, provider: string, sessionId: string, desktopIds?: Map<string, string[]>): OrganizerRowFields {
|
|
119
|
+
if (!o) return {}
|
|
120
|
+
const id = sessionId.toLowerCase()
|
|
121
|
+
return o.fieldsForAny([organizerKey(provider, id)], provider === 'claude' ? desktopIds?.get(id) ?? [] : [])
|
|
122
|
+
}
|
|
94
123
|
|
|
95
124
|
export const agentSessionsRouter = Router()
|
|
96
125
|
// Preview engine phase 1: GET /api/preview/agent-sessions/... and /api/preview-folders/settings (routes/session-preview.ts).
|
|
@@ -511,7 +540,17 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
511
540
|
const peers = await liveClaudePeerRecords()
|
|
512
541
|
const live = liveClaudeRows(peers)
|
|
513
542
|
const dropped = emptySessionListDropped()
|
|
514
|
-
|
|
543
|
+
// Step 2a: COS pins and folder members are listed at any age, outside the cap and `limit`.
|
|
544
|
+
const organizer = organizerForList()
|
|
545
|
+
const desktopIds = organizer ? desktopIdsByCli(aliases) : undefined
|
|
546
|
+
// 7.0.25 (lead 2026-11-11 10:29): kept rows only for a client that ASKS (`kept=1`, Control Web). The public lens
|
|
547
|
+
// (6.11.681) and Control Mac (0.5.389) do not clamp to their own limit, so a server-only update would have added
|
|
548
|
+
// up to KEPT_LIST_MAX rows to their lists unasked. Without the parameter the walk gets no keep set at all: exactly
|
|
549
|
+
// the 7.0.18 selection (same rows, same limit). The organizer fields on rows stay (additive, ignored by them).
|
|
550
|
+
const wantsKept = req.query.kept === '1'
|
|
551
|
+
// Miles, 2026-11-10: sessions a COS job started in the last 7 days stay listed (memoized journal read).
|
|
552
|
+
const started = wantsKept ? await cosStartedSessionIds() : {}
|
|
553
|
+
const sessions = await listAgentSessions(roots, new Date(), live, limit, sort, dropped, aliases, wantsKept && organizer ? organizer.keptIds() : {}, started)
|
|
515
554
|
// 6.45.5: each row's last real activity, read from its transcript records. Memoized on
|
|
516
555
|
// mtime and size, so a steady poll re-reads nothing; bounded so a cold list of 80 rows
|
|
517
556
|
// never holds 80 descriptors at once.
|
|
@@ -583,8 +622,12 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
583
622
|
const signal = signalFor(row.session_id)
|
|
584
623
|
const facts = rolloutById.get(row.session_id) ?? null
|
|
585
624
|
const hint = runningHintFor(row.provider, derived, hintClock(signal, derived, facts), now, clocks.get(row.session_id) ?? null)
|
|
625
|
+
// Step 2a: the model only from the memo (never a read on the list path, 6.62.0 W11).
|
|
626
|
+
const model = cachedListModel(row.provider, row.file, Number.isFinite(Date.parse(row.modified)) ? Date.parse(row.modified) : null)
|
|
586
627
|
return {
|
|
587
628
|
...withRunning(toEntry(row, activity[index], derived, queuedOf(row.provider, row.session_id)), running, hint),
|
|
629
|
+
...organizerFieldsOf(organizer, row.provider, row.session_id, desktopIds),
|
|
630
|
+
...(model ? { reported_model: model } : {}),
|
|
588
631
|
...cursorActivityFields(row.provider, signal),
|
|
589
632
|
...(row.provider === 'codex'
|
|
590
633
|
? { ...(subagents.has(row.session_id) ? { subagent_activity: subagents.get(row.session_id) } : {}), ...codexCompactionFields(workObservationFor(row.session_id), facts, now) }
|
|
@@ -602,13 +645,57 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
|
|
|
602
645
|
// client ignores this key. Zero means the walk found nothing to hide, not
|
|
603
646
|
// that the caps are off.
|
|
604
647
|
dropped,
|
|
648
|
+
...(organizer ? { organizer: { rev: organizer.rev() } } : {}),
|
|
605
649
|
}))
|
|
650
|
+
// Step 2a: read the models this answer lacked, in the background, for a later poll. Never awaited.
|
|
651
|
+
warmListModels(sessions)
|
|
606
652
|
} catch (error) {
|
|
607
653
|
console.error(`[agent-sessions] list failed: ${error instanceof Error ? error.message : error}`)
|
|
608
654
|
res.status(500).json({ error: 'Failed to read agent sessions', reason: 'agent_sessions_read_failed' })
|
|
609
655
|
}
|
|
610
656
|
})
|
|
611
657
|
|
|
658
|
+
// Step 2a (S1_CONTRACT_step2a.md B2): the last `days` days in a short form, paged, for the compact groups. Not on the
|
|
659
|
+
// list poll: fetched when the list opens and as a group unfolds. Live rows, COS-kept rows and each agent's own pins are
|
|
660
|
+
// left out (the main list has them in full); a row may still also be in the main list, and the client keeps that one.
|
|
661
|
+
agentSessionsRouter.get('/agent-sessions/older', async (req, res) => {
|
|
662
|
+
res.set('Cache-Control', 'private, no-store')
|
|
663
|
+
try {
|
|
664
|
+
const days = boundedInteger(req.query.days, OLDER_DEFAULT_DAYS, OLDER_MIN_DAYS, OLDER_MAX_DAYS)
|
|
665
|
+
const limit = boundedInteger(req.query.limit, OLDER_DEFAULT_LIMIT, 1, OLDER_MAX_LIMIT)
|
|
666
|
+
const beforeRaw = typeof req.query.before === 'string' ? Date.parse(req.query.before) : NaN
|
|
667
|
+
if (typeof req.query.before === 'string' && !Number.isFinite(beforeRaw)) {
|
|
668
|
+
res.status(400).json({ error: 'before must be an ISO time', reason: 'invalid_before' })
|
|
669
|
+
return
|
|
670
|
+
}
|
|
671
|
+
const roots = agentSessionRoots()
|
|
672
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
673
|
+
const organizer = organizerForList()
|
|
674
|
+
const kept = organizer ? organizer.keptIds() : { claude: new Set<string>(), codex: new Set<string>(), cursor: new Set<string>() }
|
|
675
|
+
const started = await cosStartedSessionIds()
|
|
676
|
+
for (const provider of ['claude', 'codex', 'cursor'] as const) for (const id of started[provider]) kept[provider].add(id)
|
|
677
|
+
// Kept Claude ids may be Desktop ids; the walk names transcripts by CLI id.
|
|
678
|
+
for (const id of [...kept.claude]) { const cli = aliases.get(id)?.cliSessionId; if (cli && !cli.startsWith('!')) kept.claude.add(cli) }
|
|
679
|
+
const liveIds = new Set(liveClaudeRows(await liveClaudePeerRecords()).map(row => row.session_id.toLowerCase()))
|
|
680
|
+
const result = await listOlderSessions(roots, new Date(), {
|
|
681
|
+
days, limit, aliases,
|
|
682
|
+
...(Number.isFinite(beforeRaw) ? { before: beforeRaw } : {}),
|
|
683
|
+
// Kept rows come back here at any age: the main list holds at most KEPT_LIST_MAX of them (Q1 FIX 1).
|
|
684
|
+
exclude: { claude: liveIds },
|
|
685
|
+
keep: kept,
|
|
686
|
+
})
|
|
687
|
+
res.json(redactLocalPathsDeep({
|
|
688
|
+
sessions: result.rows.map(row => ({ ...row, ...organizerFieldsOf(organizer, row.provider, row.session_id, desktopIdsByCli(aliases)) })),
|
|
689
|
+
windowDays: days,
|
|
690
|
+
next_before: result.nextBefore,
|
|
691
|
+
...(Number.isFinite(beforeRaw) ? {} : { older_than_window: result.olderThanWindow }),
|
|
692
|
+
}))
|
|
693
|
+
} catch (error) {
|
|
694
|
+
console.error(`[agent-sessions] older list failed: ${error instanceof Error ? error.message : error}`)
|
|
695
|
+
res.status(500).json({ error: 'Failed to read older agent sessions', reason: 'agent_sessions_read_failed' })
|
|
696
|
+
}
|
|
697
|
+
})
|
|
698
|
+
|
|
612
699
|
agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
613
700
|
res.set('Cache-Control', 'private, no-store')
|
|
614
701
|
const query = typeof req.query.q === 'string' ? req.query.q.trim() : ''
|
|
@@ -619,6 +706,23 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
619
706
|
try {
|
|
620
707
|
const limit = boundedInteger(req.query.limit, 20, 1, 50)
|
|
621
708
|
const result = await searchAgentSessions({ query, limit })
|
|
709
|
+
// Step 2a: a session renamed in COS is found by its new title too (the agent's own title still matches as before).
|
|
710
|
+
const organizer = organizerForList()
|
|
711
|
+
if (organizer) {
|
|
712
|
+
const seen = new Set(result.hits.map(hit => organizerKey(hit.provider, hit.session_id)))
|
|
713
|
+
const roots = agentSessionRoots()
|
|
714
|
+
const extra: AgentSessionSearchHit[] = []
|
|
715
|
+
for (const match of organizer.titleMatches(query)) {
|
|
716
|
+
if (seen.has(organizerKey(match.provider, match.session_id)) || extra.length + result.hits.length >= limit) continue
|
|
717
|
+
const file = await findAgentSessionFile(match.provider, match.session_id, roots).catch(() => null)
|
|
718
|
+
const short = file ? await shortRowFor(match.provider, file, undefined, roots) : null
|
|
719
|
+
if (!short) continue
|
|
720
|
+
extra.push({ session_id: short.session_id, provider: short.provider, display_label: short.display_label, project: short.project,
|
|
721
|
+
modified: short.modified, created: short.created, alive: false, state: 'recent', pinned: false,
|
|
722
|
+
snippet: '', keywordScore: 1, semanticScore: 0, match: 'keyword' })
|
|
723
|
+
}
|
|
724
|
+
result.hits = [...extra, ...result.hits]
|
|
725
|
+
}
|
|
622
726
|
const queuedOf = await allQueuedWaitingLookup(Date.now())
|
|
623
727
|
// 6.48.1: search hits carry the same derived state as list rows (no list/search shape
|
|
624
728
|
// drift), from the registry and the hook signal; a hit has no transcript walk.
|
|
@@ -644,7 +748,7 @@ agentSessionsRouter.get('/agent-sessions/search', async (req, res) => {
|
|
|
644
748
|
// 7.0.4 S1: search hits carry snippets of the same words.
|
|
645
749
|
res.json(redactLocalPathsDeep({
|
|
646
750
|
...result,
|
|
647
|
-
hits: result.hits.map(hit => toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit))),
|
|
751
|
+
hits: result.hits.map(hit => ({ ...toSearchHit(hit, queuedOf(hit.provider, hit.session_id), derivedFor(hit)), ...organizerFieldsOf(organizer, hit.provider, hit.session_id) })),
|
|
648
752
|
}))
|
|
649
753
|
} catch (error) {
|
|
650
754
|
console.error(`[agent-sessions] search failed: ${error instanceof Error ? error.message : error}`)
|
package/server/routes/health.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
|
|
|
10
10
|
import { customInstructionsCapability, learnedStyleCapability, polishOptionsCapability } from './dictation-polish-settings.js'
|
|
11
11
|
import { PAIRING_PROTOCOL_VERSION } from '../lib/glasses-pairing.js'
|
|
12
12
|
import { taskBridgeAvailable } from '../lib/task-bridge.js'
|
|
13
|
+
import { modelEfforts } from '../lib/model-efforts.js'
|
|
13
14
|
import { Router } from 'express'
|
|
14
15
|
import { webKeyAuthorityEnabled } from '../lib/web-key-authority.js'
|
|
15
16
|
import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
|
|
@@ -712,6 +713,8 @@ healthRouter.get('/models', async (req, res) => {
|
|
|
712
713
|
...cursorOptions,
|
|
713
714
|
...ollamaOptions,
|
|
714
715
|
],
|
|
716
|
+
// Step 2a (Sessions chat-style layout): each agent's efforts, the values POST /api/query-jobs accepts. Additive.
|
|
717
|
+
efforts: modelEfforts(),
|
|
715
718
|
cursor: cursorCatalog,
|
|
716
719
|
cursorReady: isCursorProviderReady(),
|
|
717
720
|
ollama: {
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session organizer routes (Sessions chat-style layout, step 2a; S1_CONTRACT_step2a.md, A1).
|
|
3
|
+
*
|
|
4
|
+
* GET /api/session-folders
|
|
5
|
+
* POST /api/session-folders { name }
|
|
6
|
+
* PATCH /api/session-folders/:id { name }
|
|
7
|
+
* DELETE /api/session-folders/:id (its sessions leave it; no session is deleted)
|
|
8
|
+
* PATCH /api/agent-sessions/:provider/:sessionId/organizer { folder_id?, cos_pinned?, title? }
|
|
9
|
+
*
|
|
10
|
+
* Behind index.ts's /api gate (API token, or a browser cookie with `x-cos-web: 1` and a same-origin Origin).
|
|
11
|
+
* The session route canonicalizes the id from the transcript it finds, so a Claude Desktop id and its CLI id are
|
|
12
|
+
* one key, and a session the server cannot find is refused (404) instead of stored.
|
|
13
|
+
* Logs carry reason codes only: never a folder name or a title.
|
|
14
|
+
*/
|
|
15
|
+
import { Router, type Request, type Response } from 'express'
|
|
16
|
+
import {
|
|
17
|
+
agentSessionRoots,
|
|
18
|
+
findAgentSessionFile,
|
|
19
|
+
idFromCodexFilename,
|
|
20
|
+
isSafeSessionId,
|
|
21
|
+
loadClaudeDesktopAliases,
|
|
22
|
+
normalizeClaudeSessionId,
|
|
23
|
+
type AgentProvider,
|
|
24
|
+
type AgentSessionRoots,
|
|
25
|
+
} from '../lib/agent-session-store.js'
|
|
26
|
+
import { OrganizerError, organizerKey, sessionOrganizer, type SessionOrganizer } from '../lib/session-organizer.js'
|
|
27
|
+
|
|
28
|
+
const PROVIDERS: ReadonlySet<string> = new Set(['claude', 'codex', 'cursor'])
|
|
29
|
+
|
|
30
|
+
export interface SessionOrganizerRouteDeps {
|
|
31
|
+
organizer: () => SessionOrganizer
|
|
32
|
+
roots: () => AgentSessionRoots
|
|
33
|
+
/** The canonical id of a session the server can find on disk, or null. */
|
|
34
|
+
canonicalId: (provider: AgentProvider, sessionId: string, roots: AgentSessionRoots) => Promise<SessionIdentity | null>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where a session's organizer entry lives: its canonical id, and for Claude the Desktop id it was named by when one is
|
|
38
|
+
* known. `null`: the server cannot find the session. Throws 409 alias_conflict while Claude Desktop's records disagree
|
|
39
|
+
* about the session's CLI id (Q1 FIX 2): an entry written then could be orphaned when they settle. */
|
|
40
|
+
export interface SessionIdentity { id: string; desktopId?: string }
|
|
41
|
+
|
|
42
|
+
/** The id a found transcript names: Claude's CLI uuid (a Desktop id resolves to it), Codex's rollout id, Cursor's composer. */
|
|
43
|
+
export async function canonicalSessionId(provider: AgentProvider, sessionId: string, roots: AgentSessionRoots): Promise<SessionIdentity | null> {
|
|
44
|
+
if (!isSafeSessionId(sessionId)) return null
|
|
45
|
+
if (provider === 'claude') {
|
|
46
|
+
// A Desktop id may arrive as `local_<uuid>`; the alias table and transcripts use the bare uuid.
|
|
47
|
+
sessionId = normalizeClaudeSessionId(sessionId)
|
|
48
|
+
const aliases = await loadClaudeDesktopAliases(roots.claudeCodeSessions)
|
|
49
|
+
if (aliases.has(sessionId) && aliases.get(sessionId) === null) throw new OrganizerError(409, 'alias_conflict')
|
|
50
|
+
const head = aliases.get(sessionId)
|
|
51
|
+
const file = await findAgentSessionFile('claude', sessionId, roots, new Date(), aliases)
|
|
52
|
+
if (file) {
|
|
53
|
+
const id = (file.split('/').pop() || '').replace(/\.jsonl$/, '').toLowerCase()
|
|
54
|
+
if (!id) return null
|
|
55
|
+
// The Desktop id: the one asked for when it is an alias, else the single Desktop record that names this CLI id.
|
|
56
|
+
let desktopId: string | undefined = head ? sessionId : undefined
|
|
57
|
+
if (!desktopId) {
|
|
58
|
+
const named = [...aliases].filter(([, h]) => h?.cliSessionId === id).map(([d]) => d)
|
|
59
|
+
if (named.length === 1) desktopId = named[0]
|
|
60
|
+
}
|
|
61
|
+
return { id, ...(desktopId && desktopId !== id ? { desktopId } : {}) }
|
|
62
|
+
}
|
|
63
|
+
// A Desktop-only session (no CLI transcript yet): keyed by its Desktop id, which the list also shows.
|
|
64
|
+
if (head) return { id: sessionId, desktopId: sessionId }
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
67
|
+
const file = await findAgentSessionFile(provider, sessionId, roots)
|
|
68
|
+
if (!file) return null
|
|
69
|
+
const parts = file.split('/')
|
|
70
|
+
const id = provider === 'codex'
|
|
71
|
+
? idFromCodexFilename(parts[parts.length - 1] || '')
|
|
72
|
+
: (parts[parts.length - 2] || '').toLowerCase() || null
|
|
73
|
+
return id ? { id } : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fail(res: Response, error: unknown): void {
|
|
77
|
+
if (error instanceof OrganizerError) {
|
|
78
|
+
res.status(error.status).json({ error: error.reason, reason: error.reason })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
console.error(`[session-organizer] write failed (reason=${(error as NodeJS.ErrnoException)?.code ?? 'error'})`)
|
|
82
|
+
res.status(500).json({ error: 'organizer_write_failed', reason: 'organizer_write_failed' })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createSessionOrganizerRouter(deps: SessionOrganizerRouteDeps = {
|
|
86
|
+
organizer: sessionOrganizer,
|
|
87
|
+
roots: () => agentSessionRoots(),
|
|
88
|
+
canonicalId: canonicalSessionId,
|
|
89
|
+
}): Router {
|
|
90
|
+
const router = Router()
|
|
91
|
+
const noStore = (res: Response) => res.set('Cache-Control', 'private, no-store')
|
|
92
|
+
|
|
93
|
+
router.get('/session-folders', (_req: Request, res: Response) => {
|
|
94
|
+
noStore(res)
|
|
95
|
+
const o = deps.organizer()
|
|
96
|
+
if (!o.isAvailable()) return void res.status(503).json({ error: 'organizer_unavailable', reason: 'organizer_unavailable' })
|
|
97
|
+
res.json({ rev: o.rev(), folders: o.listFolders() })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
router.post('/session-folders', (req: Request, res: Response) => {
|
|
101
|
+
noStore(res)
|
|
102
|
+
try {
|
|
103
|
+
const o = deps.organizer()
|
|
104
|
+
const folder = o.createFolder((req.body as { name?: unknown } | undefined)?.name)
|
|
105
|
+
res.status(201).json({ rev: o.rev(), folder })
|
|
106
|
+
} catch (error) { fail(res, error) }
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
router.patch('/session-folders/:id', (req: Request, res: Response) => {
|
|
110
|
+
noStore(res)
|
|
111
|
+
try {
|
|
112
|
+
const o = deps.organizer()
|
|
113
|
+
const folder = o.renameFolder(String(req.params.id), (req.body as { name?: unknown } | undefined)?.name)
|
|
114
|
+
res.json({ rev: o.rev(), folder })
|
|
115
|
+
} catch (error) { fail(res, error) }
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
router.delete('/session-folders/:id', (req: Request, res: Response) => {
|
|
119
|
+
noStore(res)
|
|
120
|
+
try {
|
|
121
|
+
const o = deps.organizer()
|
|
122
|
+
const released = o.deleteFolder(String(req.params.id))
|
|
123
|
+
res.json({ rev: o.rev(), released })
|
|
124
|
+
} catch (error) { fail(res, error) }
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
router.patch('/agent-sessions/:provider/:sessionId/organizer', async (req: Request, res: Response) => {
|
|
128
|
+
noStore(res)
|
|
129
|
+
const provider = String(req.params.provider)
|
|
130
|
+
if (!PROVIDERS.has(provider)) return void res.status(400).json({ error: 'invalid_provider', reason: 'invalid_provider' })
|
|
131
|
+
// A Claude Desktop id arrives as `local_<uuid>`; the bare uuid is the id everywhere else.
|
|
132
|
+
const sessionId = provider === 'claude' ? normalizeClaudeSessionId(String(req.params.sessionId)) : String(req.params.sessionId)
|
|
133
|
+
if (!isSafeSessionId(sessionId)) return void res.status(400).json({ error: 'invalid_session_id', reason: 'invalid_session_id' })
|
|
134
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body as Record<string, unknown> : {}
|
|
135
|
+
const change: { folder_id?: unknown; cos_pinned?: unknown; title?: unknown } = {}
|
|
136
|
+
for (const key of ['folder_id', 'cos_pinned', 'title'] as const) if (Object.prototype.hasOwnProperty.call(body, key)) change[key] = body[key]
|
|
137
|
+
try {
|
|
138
|
+
const o = deps.organizer()
|
|
139
|
+
if (!o.isAvailable()) throw new OrganizerError(503, 'organizer_unavailable')
|
|
140
|
+
if (Object.keys(change).length === 0) throw new OrganizerError(400, 'empty_change')
|
|
141
|
+
const identity = await deps.canonicalId(provider as AgentProvider, sessionId, deps.roots())
|
|
142
|
+
if (!identity) throw new OrganizerError(404, 'session_not_found')
|
|
143
|
+
const fields = o.updateSession(organizerKey(provider, identity.id), change, { desktopId: identity.desktopId })
|
|
144
|
+
res.json({ rev: o.rev(), session: { provider, session_id: identity.id, ...fields } })
|
|
145
|
+
} catch (error) { fail(res, error) }
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return router
|
|
149
|
+
}
|
|
@@ -21,7 +21,7 @@ import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-
|
|
|
21
21
|
import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
|
|
22
22
|
import { CLIENT_TURN_ID_RE, COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
|
|
23
23
|
import {
|
|
24
|
-
admitToQueue, drainDecision, queueableRefusal, queuePosition,
|
|
24
|
+
admitToQueue, drainDecision, holdReasonFor, queueableRefusal, queuePosition,
|
|
25
25
|
MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
|
|
26
26
|
queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
|
|
27
27
|
steerMidturnEnabled, type SessionSteerAvailability,
|
|
@@ -153,6 +153,14 @@ export function steerAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe
|
|
|
153
153
|
return sessionSteerAvailability({ provider, gateReason, continueLive, peerInbox: probe.peerInbox, waitingOnPrompt: probe.waitingOnPrompt })
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* When a sweep last decided each held row (epoch ms), in memory only: the row file is written when its hold
|
|
158
|
+
* reason CHANGES, not every 20 s. Bounded; a settled row is forgotten.
|
|
159
|
+
*/
|
|
160
|
+
const holdCheckedAt = new Map<string, number>()
|
|
161
|
+
const HOLD_CHECKED_MAX = 1_000
|
|
162
|
+
export function __resetHoldCheckedForTests(): void { holdCheckedAt.clear() }
|
|
163
|
+
|
|
156
164
|
/** Public shape of a queued turn. No prompt echo beyond a short preview. */
|
|
157
165
|
export function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unknown> {
|
|
158
166
|
const row: Record<string, unknown> = {
|
|
@@ -169,6 +177,12 @@ export function publicRow(turn: QueuedThreadTurn, position: number): Record<stri
|
|
|
169
177
|
...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
|
|
170
178
|
// 11/07: how many files wait with the words (never their ids or paths). Absent without files.
|
|
171
179
|
...(Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0 ? { attachments: turn.attachmentIds.length } : {}),
|
|
180
|
+
// 2026-11-11: why a waiting row is held (a code), since when, and when a sweep last decided it. Additive.
|
|
181
|
+
...(turn.status === 'waiting' && typeof turn.holdReason === 'string' ? {
|
|
182
|
+
waiting_reason: turn.holdReason,
|
|
183
|
+
...(typeof turn.holdSince === 'number' ? { waiting_since: turn.holdSince } : {}),
|
|
184
|
+
...(holdCheckedAt.has(turn.clientTurnId) ? { waiting_checked_at: holdCheckedAt.get(turn.clientTurnId) } : {}),
|
|
185
|
+
} : {}),
|
|
172
186
|
}
|
|
173
187
|
// 6.78.0: additive, and only while a P3 flag is on, so the row is the 6.77 bytes otherwise.
|
|
174
188
|
// `resendable` is the per-row capability: present only when Send again can act on it.
|
|
@@ -242,7 +256,7 @@ function localDrainVerdict(at: number): OriginVerdict {
|
|
|
242
256
|
}
|
|
243
257
|
|
|
244
258
|
/** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
|
|
245
|
-
type MidTurnOutcome = 'delivered' | 'settled' | 'held'
|
|
259
|
+
type MidTurnOutcome = 'delivered' | 'settled' | 'held' | 'held_files'
|
|
246
260
|
|
|
247
261
|
/**
|
|
248
262
|
* Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
|
|
@@ -279,9 +293,11 @@ async function steerHeldTurn(
|
|
|
279
293
|
deps: ThreadTurnQueueDeps,
|
|
280
294
|
): Promise<MidTurnOutcome> {
|
|
281
295
|
if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
|
|
282
|
-
if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
|
|
283
296
|
const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
|
|
284
297
|
if (!availability || !availability.available) return 'held'
|
|
298
|
+
// Files never go into a running turn (the peer inbox carries words only). Checked AFTER availability, so
|
|
299
|
+
// `held_files` means exactly this: the turn could have gone in now but for its files (the row's hold reason).
|
|
300
|
+
if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held_files'
|
|
285
301
|
const { clientTurnId, prompt } = turn
|
|
286
302
|
|
|
287
303
|
const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
|
|
@@ -465,11 +481,19 @@ export async function drainThread(
|
|
|
465
481
|
// which also means the hold is the busy GATE: the rule answers only for a refusal that
|
|
466
482
|
// names a desk run (an attachable thread has no reason). Anything else holds as before.
|
|
467
483
|
const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
|
|
484
|
+
let reason = holdReasonFor(seen, deps.now())
|
|
468
485
|
if (!midTurnOrderHeld && !cancelHeld) {
|
|
469
486
|
const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
|
|
470
|
-
if (outcome === 'delivered') { delivered += 1; continue }
|
|
471
|
-
if (outcome === 'settled') { retired += 1; continue }
|
|
487
|
+
if (outcome === 'delivered') { holdCheckedAt.delete(turn.clientTurnId); delivered += 1; continue }
|
|
488
|
+
if (outcome === 'settled') { holdCheckedAt.delete(turn.clientTurnId); retired += 1; continue }
|
|
489
|
+
if (outcome === 'held_files') reason = 'files_wait_for_turn_end'
|
|
472
490
|
}
|
|
491
|
+
// The hold reason: written only when it changes; the check time is kept in memory (2026-11-11).
|
|
492
|
+
const at = deps.now()
|
|
493
|
+
if (turn.holdReason !== reason) { turn.holdReason = reason; turn.holdSince = at; persist(turn) }
|
|
494
|
+
holdCheckedAt.delete(turn.clientTurnId)
|
|
495
|
+
holdCheckedAt.set(turn.clientTurnId, at)
|
|
496
|
+
while (holdCheckedAt.size > HOLD_CHECKED_MAX) holdCheckedAt.delete(holdCheckedAt.keys().next().value!)
|
|
473
497
|
midTurnOrderHeld = true
|
|
474
498
|
held += 1
|
|
475
499
|
continue
|
|
@@ -480,6 +504,7 @@ export async function drainThread(
|
|
|
480
504
|
turn.settledAt = deps.now()
|
|
481
505
|
retired += 1; persist(turn)
|
|
482
506
|
forgetIfSettled(turn)
|
|
507
|
+
holdCheckedAt.delete(turn.clientTurnId)
|
|
483
508
|
continue
|
|
484
509
|
}
|
|
485
510
|
|
|
@@ -488,6 +513,10 @@ export async function drainThread(
|
|
|
488
513
|
// forever; the ceiling only bounds anything if it survives the crash it is bounding.
|
|
489
514
|
turn.attempts += 1
|
|
490
515
|
turn.status = 'delivering'
|
|
516
|
+
// No longer held: the reason belonged to the wait that just ended.
|
|
517
|
+
delete turn.holdReason
|
|
518
|
+
delete turn.holdSince
|
|
519
|
+
holdCheckedAt.delete(turn.clientTurnId)
|
|
491
520
|
// 6.78.0: the stamp the restart reconcile reads. `journal: 1` says this build keeps an
|
|
492
521
|
// attempt record for the delivery that follows; `deliveringBootId` says which boot owns
|
|
493
522
|
// it, so a sweep never touches a row this process is delivering right now. Durable:
|
package/server/routes/web-key.ts
CHANGED
|
@@ -43,9 +43,21 @@ export interface WebKeyRouterDeps {
|
|
|
43
43
|
isWebRequest: (req: Request) => boolean
|
|
44
44
|
apiToken: () => string
|
|
45
45
|
selfDnsName: () => string | null
|
|
46
|
+
/**
|
|
47
|
+
* 7.0.14: when selfDnsName() is null, GET status (after its auth check) awaits this before it
|
|
48
|
+
* answers, so a boot-time miss does not hand COS Control `origin: null` (lib/tailnet-self-name.ts
|
|
49
|
+
* refreshIfMissing: bounded, single-flight, rate-limited). Never called while a name is known.
|
|
50
|
+
*/
|
|
51
|
+
refreshSelfDnsName?: () => Promise<unknown>
|
|
46
52
|
expectedOrigin: () => string | null
|
|
47
53
|
/** Served-asset integrity (Phase 6) for Control's audit; absent: not reported. */
|
|
48
54
|
integrityStatus?: () => Record<string, unknown>
|
|
55
|
+
/**
|
|
56
|
+
* B2: whether 3144 presents a publicly trusted certificate for this Mac's tailnet name, and why not
|
|
57
|
+
* (lib/web-key-signer-tls.ts SignerTlsStatus, e.g. `tailnet_https_certs_off`). Authority mode only;
|
|
58
|
+
* absent or null (no 3144 listener): not reported.
|
|
59
|
+
*/
|
|
60
|
+
signerTls?: () => object | null
|
|
49
61
|
/**
|
|
50
62
|
* Phase 3: whether this request reached the 3144 signer listener (index.ts: the socket's local
|
|
51
63
|
* port). Given, the phone's key steps (enroll start and prove, nonce) and the handoff claim answer
|
|
@@ -97,13 +109,27 @@ export function createWebKeyRouter(deps: WebKeyRouterDeps): Router {
|
|
|
97
109
|
next()
|
|
98
110
|
}
|
|
99
111
|
|
|
100
|
-
|
|
112
|
+
// 7.0.14: a request that passed the status route's own auth check, while this Mac's tailnet name is
|
|
113
|
+
// unknown, waits for one bounded lookup instead of answering a stale miss. Known name: no wait.
|
|
114
|
+
const learnSelfName = async (): Promise<void> => {
|
|
115
|
+
if (!deps.refreshSelfDnsName || deps.selfDnsName() !== null) return
|
|
116
|
+
try { await deps.refreshSelfDnsName() } catch { /* answer with what is known */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const signerTlsField = (): { signerTls?: object } => {
|
|
120
|
+
let state: object | null = null
|
|
121
|
+
try { state = deps.signerTls?.() ?? null } catch { state = null }
|
|
122
|
+
return state ? { signerTls: state } : {}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
router.get('/web-key/status', anchorRoutes, async (req, res) => {
|
|
101
126
|
const registry = deps.authority.registry
|
|
102
|
-
const selfDns = deps.selfDnsName()
|
|
103
127
|
if (deps.authority.mode() !== 'authority') {
|
|
104
128
|
// Bootstrap only: COS Control's anchor state, nothing that suggests approvals. Token only:
|
|
105
129
|
// no web page needs it while no phone can enroll. `enabled: false` keeps older Controls hidden.
|
|
106
130
|
if (!hasToken(req)) return refuse(res, 401, 'unauthorized')
|
|
131
|
+
await learnSelfName()
|
|
132
|
+
const selfDns = deps.selfDnsName()
|
|
107
133
|
const anchor = registry.anchor()
|
|
108
134
|
return void res.json({
|
|
109
135
|
ok: true,
|
|
@@ -118,6 +144,8 @@ export function createWebKeyRouter(deps: WebKeyRouterDeps): Router {
|
|
|
118
144
|
})
|
|
119
145
|
}
|
|
120
146
|
if (!hasToken(req) && !web(req)) return refuse(res, 401, 'unauthorized')
|
|
147
|
+
await learnSelfName()
|
|
148
|
+
const selfDns = deps.selfDnsName()
|
|
121
149
|
res.json({
|
|
122
150
|
ok: true,
|
|
123
151
|
enabled: true,
|
|
@@ -131,6 +159,7 @@ export function createWebKeyRouter(deps: WebKeyRouterDeps): Router {
|
|
|
131
159
|
epoch: registry.epoch(),
|
|
132
160
|
activeKeys: registry.activeCount(selfDns),
|
|
133
161
|
...(deps.integrityStatus ? { integrity: deps.integrityStatus() } : {}),
|
|
162
|
+
...signerTlsField(),
|
|
134
163
|
})
|
|
135
164
|
})
|
|
136
165
|
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
"v": 1,
|
|
3
3
|
"algorithm": "sha256",
|
|
4
4
|
"files": {
|
|
5
|
-
"/app.css": "
|
|
6
|
-
"/app.js": "
|
|
5
|
+
"/app.css": "1204f3b57d312c94e96a1430ba036292873ecbe132073c8a4f03e37063172a70",
|
|
6
|
+
"/app.js": "1491f6dd957f47881e55a47caf98a3ce8fcb6df43a58b6e6877dd86807bebea6",
|
|
7
7
|
"/apple-touch-icon.png": "04ec7eca0bf6f53d89900d146ac32d4826759d6258bcdbd1ccdcc517f26f532c",
|
|
8
8
|
"/control.css": "e7b287aa4acc1b3895a9ce61512fe449c555b6335a7145684fd78108ae008c75",
|
|
9
9
|
"/fonts/DMSans.ttf": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d",
|
|
10
10
|
"/fonts/Fraunces.ttf": "177ff6c0f14e5550a3c624247cd1189611d4eb65d000b14944c63d967958abbb",
|
|
11
11
|
"/fonts/JetBrainsMono.ttf": "48715a42ec242c21e9f02692891e147d022299a52e48d5e413e1a942193ffeda",
|
|
12
|
-
"/index.html": "
|
|
12
|
+
"/index.html": "8664ebf9ae7635c1fcf5fce333b89d272b6ce0df9529120523b99a3d49eb5ebb",
|
|
13
13
|
"/mark.svg": "8e380d33d393027df192eb1afa08f15a380c2070c72d68f27311b49d9e94ca18",
|
|
14
14
|
"/memories/d3.min.js": "f2094bbf6141b359722c4fe454eb6c4b0f0e42cc10cc7af921fc158fceb86539",
|
|
15
15
|
"/memories/graph-explorer.css": "ec128de59811f45c71b6bededd737989309205e903951eb8211ade7664f87486",
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"/panes/questions.css": "5c5a9b32ce32386b508ce326b17d40ecacbb0f2e049df7f4fe2a0009bd3b7908",
|
|
29
29
|
"/panes/questions.js": "228e8cd0a9651a290cbb380db487af677a2fc7e93a64b1e33c9411cd8b013478",
|
|
30
30
|
"/panes/reader-media.js": "4cf892a855aa2d0dce6978e69296cd49b8036d8fc62f12773b3fcadc210b2ef0",
|
|
31
|
-
"/panes/sessions.css": "
|
|
31
|
+
"/panes/sessions.css": "654e0509ab2b699b9ea92b6d9e2f7522146df1f0b6355915bef03caaf63addc4",
|
|
32
32
|
"/panes/settings.js": "5c46c951fdcc2fc942486ec4b468b7e0bb0350a0a56a38d429948fc5147ab2f4",
|
|
33
33
|
"/panes/speakers.css": "49e70dae4266151b14b0057f2e43de169de225b8f3ac30343c81cf532735a487",
|
|
34
34
|
"/panes/speakers.js": "8132c9244fb3965de802680840d2985978571988b9b4462b347d079330251bc5",
|
|
35
35
|
"/panes/threads.js": "45fd7b56480be53225e91193cfa4c41a4ee7167890f10560e439bc7d2461747f",
|
|
36
36
|
"/panes/today-actions.css": "c82f95b2aaf09802b15d47b24eccdb5c974afcab38ff5f861b3a8d8630b60869",
|
|
37
|
-
"/panes/today-actions.js": "
|
|
37
|
+
"/panes/today-actions.js": "57ae8b378b1061060ae2a3f61d227d440d7806538085c1d5b310239c036e4bdc",
|
|
38
38
|
"/panes/work.css": "da50355dac690f712d1f6331f70ee5eadbf5982d37508648ae7786afc99ac7b1",
|
|
39
|
-
"/panes/work.js": "
|
|
39
|
+
"/panes/work.js": "1ef872b8c7e1a57bd7bd314e11eee7ac5fa9668871da7e644a1fbf5fd9c0d9e8",
|
|
40
40
|
"/shell.js": "f1cc6a52c78e01ddba89ff27c6bd0e8f8bd8f29bdfcbb5150ee4ed66a8128d9d",
|
|
41
|
-
"/theme.js": "
|
|
41
|
+
"/theme.js": "e3e6422eaac43979d03149da114f5b83757b7361dd7b309cdf76be36a596044b",
|
|
42
42
|
"/vendor/qrcode-encoder.js": "61d87cc7445cc66b862cff913de4571634746ea62bcd50d0f592be14f6d1ce46"
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -106,6 +106,15 @@ a { color: var(--accent); }
|
|
|
106
106
|
svg.icon { width: 22px; height: 22px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
107
107
|
|
|
108
108
|
/* ── Sign in ─────────────────────────────────────────────── */
|
|
109
|
+
/* 7.0.19 first paint: the mark on the page's background until the sign-in card or the app shows (index.html #first-paint).
|
|
110
|
+
Fixed, so it is out of the layout and nothing shifts when it goes. Only while theme.js has run (html.fp-wait), so it
|
|
111
|
+
never shows with JavaScript off. The failsafe hides it after 25 s, longer than the 20 s sign-in check, so it ends the
|
|
112
|
+
wait only when the scripts never ran; reduced motion shortens durations, not this delay. */
|
|
113
|
+
.first-paint { display: none; }
|
|
114
|
+
html.fp-wait .first-paint { position: fixed; inset: 0; z-index: 70; display: grid; place-items: center; background: var(--page); color: var(--ink); pointer-events: none; animation: fp-failsafe 1ms linear 25s forwards; }
|
|
115
|
+
.first-paint .mark { width: 44px; height: 44px; }
|
|
116
|
+
@keyframes fp-failsafe { to { visibility: hidden; } }
|
|
117
|
+
.noscript-note { margin: 0; min-height: 100dvh; display: grid; place-items: center; padding: 24px var(--gutter); font-size: 17px; color: var(--ink); text-align: center; }
|
|
109
118
|
.auth-gate { min-height: 100dvh; display: grid; place-items: center; padding: calc(var(--safe-top) + 24px) var(--gutter) calc(var(--safe-bottom) + 24px); }
|
|
110
119
|
.auth-card { width: 100%; max-width: 420px; }
|
|
111
120
|
.auth-card .mark { width: 44px; height: 44px; margin-bottom: 22px; }
|
|
@@ -644,3 +653,57 @@ html { touch-action: manipulation; }
|
|
|
644
653
|
.sx-te-choices, .ta-te-choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 6px; }
|
|
645
654
|
.sx-te-choices .cx-btn, .ta-te-choices .cx-btn { flex: 1 1 12rem; min-height: 44px; white-space: normal; text-align: center; line-height: 1.25; }
|
|
646
655
|
.sx-mac-q .sx-note { margin: 6px 0 0; }
|
|
656
|
+
|
|
657
|
+
/* ── 7.0.29: the folded Duo's side rail (Miles 21:39 "These side buttons are a must on the duo"; 21:42 tall phones keep
|
|
658
|
+
the top menu). theme.js sets html.short-screen from the screen's shape; this draws it only under 600 px on a touch
|
|
659
|
+
screen, so nothing changes from 600 px, on a mouse, or on a tall phone. It is the same nav.rail as the wide rail, so
|
|
660
|
+
the panes, their order, icons, badges and aria-current are one list: here it moves to the right edge as icons only,
|
|
661
|
+
with the top bar's Back at its foot on a reader. --rail-hit is 44 points on the phone: app.js railHitSync sets it from
|
|
662
|
+
the zoom (52 px at Safari's 85 percent Page Zoom, 44 px at 100); 52 px until it runs. Colours are the rail's own
|
|
663
|
+
tokens; the current pane keeps the wide rail's look (rail-active fill and its small shadow, no border). */
|
|
664
|
+
.rail-back { display: none; }
|
|
665
|
+
@media (max-width: 599px) and (pointer: coarse) {
|
|
666
|
+
html.short-screen { --rail-w: calc(var(--rail-hit, 52px) + 6px); }
|
|
667
|
+
html.short-screen .app-shell { display: flex; flex-direction: row; align-items: flex-start; }
|
|
668
|
+
html.short-screen.sx-frame .app-shell { flex-direction: row; align-items: stretch; }
|
|
669
|
+
html.short-screen .main-col { flex: 1 1 auto; min-width: 0; --safe-right: 0px; }
|
|
670
|
+
html.short-screen .rail {
|
|
671
|
+
order: 2; flex: none; width: calc(var(--rail-w) + var(--safe-right)); box-sizing: border-box;
|
|
672
|
+
display: flex; flex-direction: column; align-items: center; gap: 0;
|
|
673
|
+
position: sticky; top: 0; height: 100dvh; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none;
|
|
674
|
+
padding: calc(var(--safe-top) + 4px) var(--safe-right) calc(var(--safe-bottom) + 6px) 0;
|
|
675
|
+
background: var(--rail); border-left: 1px solid var(--rail-border);
|
|
676
|
+
}
|
|
677
|
+
html.short-screen.sx-frame .rail { position: static; height: auto; min-height: 0; }
|
|
678
|
+
html.short-screen .rail::-webkit-scrollbar { display: none; }
|
|
679
|
+
html.short-screen .rail-head, html.short-screen .rail-text, html.short-screen .rail-settings .rail-label, html.short-screen .server-line { display: none; }
|
|
680
|
+
html.short-screen .rail-btn, html.short-screen .rail-settings {
|
|
681
|
+
position: relative; flex: none; display: grid; place-items: center;
|
|
682
|
+
width: var(--rail-hit, 52px); height: var(--rail-hit, 52px); min-height: 0; padding: 0;
|
|
683
|
+
border-radius: 12px; color: var(--grey);
|
|
684
|
+
}
|
|
685
|
+
html.short-screen .rail-btn .icon, html.short-screen .rail-settings .icon { width: 22px; height: 22px; }
|
|
686
|
+
html.short-screen .rail-btn[aria-current='page'], html.short-screen .rail-settings[aria-current='page'] { background: var(--rail-active); color: var(--ink); box-shadow: 0 1px 2px rgba(43, 33, 26, 0.08); }
|
|
687
|
+
html.short-screen .rail-btn:active:not([aria-current='page']), html.short-screen .rail-settings:active:not([aria-current='page']) { background: var(--rail-border); color: var(--ink); }
|
|
688
|
+
html.short-screen .rail-btn .badge-dot { top: 9px; right: 9px; }
|
|
689
|
+
html.short-screen .rail-btn.hot::after { content: ''; position: absolute; top: 9px; right: 9px; width: 8px; height: 8px; border-radius: 50%; background: var(--hot); }
|
|
690
|
+
html.short-screen .rail-btn .inbox-count { position: absolute; top: 4px; right: 2px; }
|
|
691
|
+
html.short-screen .rail-btn .inbox-dot { position: absolute; top: 9px; right: 9px; }
|
|
692
|
+
html.short-screen .rail-sep { flex: none; width: 24px; height: 1px; margin: 3px 0; background: var(--rail-border); }
|
|
693
|
+
html.short-screen .rail-spacer { display: none; }
|
|
694
|
+
/* Back: alone at the foot, outlined so it never reads as a pane; it stays on screen when the panes scroll. */
|
|
695
|
+
html.short-screen .rail-back:not([hidden]) {
|
|
696
|
+
display: grid; place-items: center; flex: none; margin-top: auto;
|
|
697
|
+
position: sticky; bottom: 0; z-index: 1;
|
|
698
|
+
width: var(--rail-hit, 52px); height: var(--rail-hit, 52px);
|
|
699
|
+
border: 1px solid var(--btn-border); border-radius: 12px; background: var(--rail); color: var(--ink);
|
|
700
|
+
}
|
|
701
|
+
html.short-screen .rail-back .icon { width: 22px; height: 22px; stroke-width: 2; }
|
|
702
|
+
html.short-screen .rail-back:active { background: var(--rail-border); }
|
|
703
|
+
/* The focus ring sits inside the button: the rail clips anything past its edge. */
|
|
704
|
+
html.short-screen .rail :focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; border-radius: 12px; }
|
|
705
|
+
/* One way to switch panes on a screen: the rail. The top bar keeps the pane's name, without the caret and its menu
|
|
706
|
+
(app.js makes the button inert), and its Back moves to the rail. */
|
|
707
|
+
html.short-screen .section-btn .caret, html.short-screen #topbar-back { display: none !important; }
|
|
708
|
+
html.short-screen .section-btn { cursor: default; }
|
|
709
|
+
}
|