create-walle 0.9.20 → 0.9.22
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/README.md +5 -5
- package/package.json +2 -2
- package/template/claude-task-manager/api-prompts.js +13 -0
- package/template/claude-task-manager/api-reviews.js +5 -2
- package/template/claude-task-manager/db.js +479 -15
- package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
- package/template/claude-task-manager/docs/image-paste-ux.md +3 -0
- package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
- package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +58 -50
- package/template/claude-task-manager/docs/phone-access-design.md +23 -7
- package/template/claude-task-manager/docs/walle-session-model-preferences.md +119 -0
- package/template/claude-task-manager/git-utils.js +146 -17
- package/template/claude-task-manager/lib/auth-rate-limit.js +23 -3
- package/template/claude-task-manager/lib/auth-rules.js +3 -0
- package/template/claude-task-manager/lib/document-review.js +33 -2
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +115 -48
- package/template/claude-task-manager/lib/mobile-auth-api.js +14 -0
- package/template/claude-task-manager/lib/remote-relay-protocol.js +5 -0
- package/template/claude-task-manager/lib/restart-guard.js +68 -0
- package/template/claude-task-manager/lib/session-standup.js +36 -13
- package/template/claude-task-manager/lib/session-stream.js +11 -4
- package/template/claude-task-manager/lib/transport-security.js +50 -0
- package/template/claude-task-manager/lib/walle-external-actions.js +20 -3
- package/template/claude-task-manager/lib/walle-transcript.js +16 -0
- package/template/claude-task-manager/lib/worktree-active-sync.js +6 -3
- package/template/claude-task-manager/public/css/reviews.css +10 -0
- package/template/claude-task-manager/public/css/setup.css +13 -0
- package/template/claude-task-manager/public/css/walle.css +145 -0
- package/template/claude-task-manager/public/index.html +564 -44
- package/template/claude-task-manager/public/ipad.html +363 -0
- package/template/claude-task-manager/public/js/document-review-links.js +196 -0
- package/template/claude-task-manager/public/js/message-renderer.js +14 -3
- package/template/claude-task-manager/public/js/reviews.js +30 -6
- package/template/claude-task-manager/public/js/setup.js +57 -13
- package/template/claude-task-manager/public/js/stream-view.js +20 -1
- package/template/claude-task-manager/public/js/walle-session.js +31 -3
- package/template/claude-task-manager/public/js/walle.js +405 -39
- package/template/claude-task-manager/public/m/app.css +1213 -39
- package/template/claude-task-manager/public/m/app.js +1887 -97
- package/template/claude-task-manager/public/m/claim.html +9 -2
- package/template/claude-task-manager/public/m/index.html +48 -7
- package/template/claude-task-manager/public/m/sw.js +1 -1
- package/template/claude-task-manager/server.js +695 -78
- package/template/claude-task-manager/session-integrity.js +4 -0
- package/template/claude-task-manager/workers/state-detectors/codex.js +18 -3
- package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +86 -35
- package/template/package.json +1 -1
- package/template/wall-e/api-walle.js +19 -1
- package/template/wall-e/brain.js +152 -6
- package/template/wall-e/chat.js +117 -2
- package/template/wall-e/coding/stream-processor.js +36 -0
- package/template/wall-e/coding-orchestrator.js +151 -12
- package/template/wall-e/docs/external-action-controller.md +60 -2
- package/template/wall-e/external-action-controller.js +23 -1
- package/template/wall-e/external-action-gateway.js +163 -0
- package/template/wall-e/fly.toml +1 -0
- package/template/wall-e/http/model-admin.js +131 -0
- package/template/wall-e/lib/service-health.js +194 -0
- package/template/wall-e/llm/anthropic.js +7 -0
- package/template/wall-e/llm/client.js +46 -12
- package/template/wall-e/llm/openai.js +17 -2
- package/template/wall-e/llm/portkey-sync.js +201 -0
- package/template/wall-e/server.js +13 -0
- package/template/wall-e/tools/local-tools.js +122 -4
- package/template/website/index.html +10 -10
|
@@ -6,7 +6,7 @@ const zlib = require('zlib');
|
|
|
6
6
|
|
|
7
7
|
const { execFileSync } = require('child_process');
|
|
8
8
|
const { normalizeAgentType } = require('./lib/agent-capabilities');
|
|
9
|
-
const { codexRolloutIdFromPath } = require('./lib/session-history');
|
|
9
|
+
const { codexRolloutIdFromPath, readCodexRolloutMetadata } = require('./lib/session-history');
|
|
10
10
|
const { ensureTranscriptTables } = require('./lib/transcript-store');
|
|
11
11
|
const {
|
|
12
12
|
classifySqliteError,
|
|
@@ -19,6 +19,7 @@ const {
|
|
|
19
19
|
const DATA_DIR = process.env.CTM_DATA_DIR || path.join(process.env.HOME, '.walle', 'data');
|
|
20
20
|
const DEFAULT_DB_PATH = path.join(DATA_DIR, 'task-manager.db');
|
|
21
21
|
const DEFAULT_IMAGES_DIR = path.join(DATA_DIR, 'images');
|
|
22
|
+
const SESSION_IMAGES_DIR = path.join(DATA_DIR, 'session-images');
|
|
22
23
|
const BACKUP_DIR = path.join(DATA_DIR, 'backups');
|
|
23
24
|
|
|
24
25
|
let db = null;
|
|
@@ -52,33 +53,57 @@ function resolveGitRoot(projectPath) {
|
|
|
52
53
|
|
|
53
54
|
function _codexFileAgentSessionId(jsonlPath) {
|
|
54
55
|
try {
|
|
55
|
-
|
|
56
|
+
const filePath = String(jsonlPath || '').trim();
|
|
57
|
+
if (!filePath) return '';
|
|
58
|
+
const pathId = codexRolloutIdFromPath(filePath);
|
|
59
|
+
if (!pathId && !String(path.basename(filePath || '')).startsWith('rollout-')) return '';
|
|
60
|
+
const meta = readCodexRolloutMetadata(filePath) || {};
|
|
61
|
+
return String(meta.id || pathId || '').trim();
|
|
56
62
|
} catch {
|
|
57
63
|
return '';
|
|
58
64
|
}
|
|
59
65
|
}
|
|
60
66
|
|
|
67
|
+
function _looksLikeCodexRolloutBasename(value) {
|
|
68
|
+
return /^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(value || '').trim());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _codexRolloutBasenameAgentSessionId(value) {
|
|
72
|
+
const match = String(value || '').trim().match(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
|
|
73
|
+
return match ? match[1].toLowerCase() : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _codexResumeAlias(value, canonicalId) {
|
|
77
|
+
const clean = String(value || '').trim();
|
|
78
|
+
if (!clean || clean === canonicalId || _looksLikeCodexRolloutBasename(clean)) return '';
|
|
79
|
+
return clean;
|
|
80
|
+
}
|
|
81
|
+
|
|
61
82
|
function _normalizeAgentSessionIdentity(data = {}) {
|
|
62
|
-
const
|
|
83
|
+
const jsonlPath = String(data.jsonlPath || '').trim();
|
|
84
|
+
const codexFileAgentSessionId = _codexFileAgentSessionId(jsonlPath);
|
|
85
|
+
const provider = (codexFileAgentSessionId || _codexRolloutBasenameAgentSessionId(data.agentSessionId))
|
|
86
|
+
? 'codex'
|
|
87
|
+
: (normalizeAgentType(data.provider || '') || String(data.provider || '').toLowerCase());
|
|
63
88
|
let agentSessionId = String(data.agentSessionId || '').trim();
|
|
64
89
|
let providerResumeId = String(data.providerResumeId || data.resumeSessionId || '').trim();
|
|
65
|
-
const jsonlPath = String(data.jsonlPath || '').trim();
|
|
66
90
|
|
|
67
|
-
if (provider === 'codex'
|
|
68
|
-
const fileAgentSessionId =
|
|
91
|
+
if (provider === 'codex') {
|
|
92
|
+
const fileAgentSessionId = codexFileAgentSessionId || _codexRolloutBasenameAgentSessionId(agentSessionId);
|
|
69
93
|
if (fileAgentSessionId) {
|
|
70
94
|
if (agentSessionId && agentSessionId !== fileAgentSessionId) {
|
|
71
|
-
if (!providerResumeId) providerResumeId = agentSessionId;
|
|
95
|
+
if (!providerResumeId) providerResumeId = _codexResumeAlias(agentSessionId, fileAgentSessionId);
|
|
96
|
+
const sourceLabel = jsonlPath ? path.basename(jsonlPath) : agentSessionId;
|
|
72
97
|
console.error(
|
|
73
|
-
`[db] Codex agent_session_id/jsonl mismatch; using
|
|
74
|
-
`instead of ${agentSessionId.slice(0, 8)} for ${
|
|
98
|
+
`[db] Codex agent_session_id/jsonl mismatch; using rollout metadata id ${fileAgentSessionId.slice(0, 8)} ` +
|
|
99
|
+
`instead of ${agentSessionId.slice(0, 8)} for ${sourceLabel}`
|
|
75
100
|
);
|
|
76
101
|
}
|
|
77
102
|
agentSessionId = fileAgentSessionId;
|
|
78
103
|
}
|
|
79
104
|
}
|
|
80
105
|
|
|
81
|
-
return { agentSessionId, providerResumeId };
|
|
106
|
+
return { agentSessionId, providerResumeId, provider };
|
|
82
107
|
}
|
|
83
108
|
|
|
84
109
|
function getDb() {
|
|
@@ -110,6 +135,7 @@ function _ensureDataDirs(dbPath) {
|
|
|
110
135
|
const dir = path.dirname(dbPath);
|
|
111
136
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
112
137
|
if (!fs.existsSync(DEFAULT_IMAGES_DIR)) fs.mkdirSync(DEFAULT_IMAGES_DIR, { recursive: true });
|
|
138
|
+
if (!fs.existsSync(SESSION_IMAGES_DIR)) fs.mkdirSync(SESSION_IMAGES_DIR, { recursive: true });
|
|
113
139
|
if (!fs.existsSync(BACKUP_DIR)) fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
|
114
140
|
}
|
|
115
141
|
|
|
@@ -1459,6 +1485,47 @@ function migrateSchemaIfNeeded() {
|
|
|
1459
1485
|
// column presence before writing title-generation status.
|
|
1460
1486
|
}
|
|
1461
1487
|
}
|
|
1488
|
+
if (getSchemaVersion() < 6) {
|
|
1489
|
+
try {
|
|
1490
|
+
migrateToV6();
|
|
1491
|
+
} catch (e) {
|
|
1492
|
+
console.error('[db] Schema migration to v6 FAILED:', e.message);
|
|
1493
|
+
console.error('[db] Stack:', e.stack);
|
|
1494
|
+
// Non-fatal: Wall-E can still fall back to startup_tasks/model defaults,
|
|
1495
|
+
// but per-session model preferences will not persist until this succeeds.
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Schema v6: Persist session-scoped model preferences.
|
|
1502
|
+
*
|
|
1503
|
+
* startup_tasks.model_id is a restore hint and intentionally has no provider
|
|
1504
|
+
* identity. Wall-E session model changes need a CTM-owned durable preference so
|
|
1505
|
+
* one tab's model switch cannot mutate Wall-E global defaults or another tab.
|
|
1506
|
+
*/
|
|
1507
|
+
function migrateToV6() {
|
|
1508
|
+
const d = getDb();
|
|
1509
|
+
d.exec(`
|
|
1510
|
+
CREATE TABLE IF NOT EXISTS session_model_preferences (
|
|
1511
|
+
ctm_session_id TEXT PRIMARY KEY,
|
|
1512
|
+
agent_type TEXT NOT NULL DEFAULT 'walle',
|
|
1513
|
+
provider_type TEXT NOT NULL DEFAULT '',
|
|
1514
|
+
provider_id TEXT NOT NULL DEFAULT '',
|
|
1515
|
+
model_id TEXT NOT NULL DEFAULT '',
|
|
1516
|
+
registry_id TEXT NOT NULL DEFAULT '',
|
|
1517
|
+
scope TEXT NOT NULL DEFAULT 'session',
|
|
1518
|
+
source TEXT NOT NULL DEFAULT 'user',
|
|
1519
|
+
pinned INTEGER NOT NULL DEFAULT 1,
|
|
1520
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
1521
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
1522
|
+
FOREIGN KEY (ctm_session_id) REFERENCES ctm_sessions(id) ON DELETE CASCADE
|
|
1523
|
+
);
|
|
1524
|
+
CREATE INDEX IF NOT EXISTS idx_session_model_preferences_agent
|
|
1525
|
+
ON session_model_preferences(agent_type, updated_at DESC);
|
|
1526
|
+
`);
|
|
1527
|
+
setSchemaVersion(6);
|
|
1528
|
+
console.log('[db] Schema migrated to v6 (session model preferences)');
|
|
1462
1529
|
}
|
|
1463
1530
|
|
|
1464
1531
|
/**
|
|
@@ -2275,6 +2342,157 @@ function deleteImage(id) {
|
|
|
2275
2342
|
}
|
|
2276
2343
|
}
|
|
2277
2344
|
|
|
2345
|
+
function _sanitizeSessionImageSegment(value, fallback) {
|
|
2346
|
+
const clean = String(value || '')
|
|
2347
|
+
.trim()
|
|
2348
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
2349
|
+
.replace(/-+/g, '-')
|
|
2350
|
+
.replace(/^[.-]+|[.-]+$/g, '')
|
|
2351
|
+
.slice(0, 120);
|
|
2352
|
+
return clean || fallback;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
function _sessionImageTimestampSlug(value) {
|
|
2356
|
+
let date = null;
|
|
2357
|
+
if (typeof value === 'number' && Number.isFinite(value)) date = new Date(value);
|
|
2358
|
+
else if (typeof value === 'string' && value.trim()) {
|
|
2359
|
+
const n = Number(value);
|
|
2360
|
+
date = Number.isFinite(n) && /^\d+$/.test(value.trim()) ? new Date(n) : new Date(value);
|
|
2361
|
+
}
|
|
2362
|
+
if (!date || Number.isNaN(date.getTime())) date = new Date();
|
|
2363
|
+
return date.toISOString().replace(/[-:.]/g, '').replace(/Z$/, 'Z');
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
function _sessionImagePromptHash(text, fallbackParts = []) {
|
|
2367
|
+
const source = String(text || '').trim() || fallbackParts.map(v => String(v || '')).join('|') || String(Date.now());
|
|
2368
|
+
return crypto.createHash('sha256').update(source).digest('hex').slice(0, 16);
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function _pathInsideDir(filePath, dirPath) {
|
|
2372
|
+
const resolvedFile = path.resolve(filePath || '');
|
|
2373
|
+
const resolvedDir = path.resolve(dirPath || '');
|
|
2374
|
+
return resolvedFile === resolvedDir || resolvedFile.startsWith(resolvedDir + path.sep);
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function _imageRowForReference(ref) {
|
|
2378
|
+
const imageId = Number(ref && ref.id || 0);
|
|
2379
|
+
if (Number.isInteger(imageId) && imageId > 0) {
|
|
2380
|
+
const row = getImage(imageId);
|
|
2381
|
+
if (row) return row;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const candidate = String(ref && (ref.file_path || ref.path) || '').trim();
|
|
2385
|
+
if (candidate && _pathInsideDir(candidate, DEFAULT_IMAGES_DIR)) {
|
|
2386
|
+
const resolved = path.resolve(candidate);
|
|
2387
|
+
const row = getDb().prepare(
|
|
2388
|
+
'SELECT * FROM images WHERE file_path = ? OR file_path LIKE ? ORDER BY id DESC LIMIT 1'
|
|
2389
|
+
).get(resolved, '%/' + path.basename(resolved));
|
|
2390
|
+
if (row) return row;
|
|
2391
|
+
if (fs.existsSync(resolved)) {
|
|
2392
|
+
return {
|
|
2393
|
+
id: null,
|
|
2394
|
+
filename: path.basename(resolved),
|
|
2395
|
+
mime_type: ref && (ref.mimeType || ref.mime_type) || 'image/png',
|
|
2396
|
+
file_path: resolved,
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
const filename = path.basename(String(ref && ref.filename || '').trim());
|
|
2402
|
+
if (filename) {
|
|
2403
|
+
const row = getDb().prepare(
|
|
2404
|
+
'SELECT * FROM images WHERE filename = ? OR file_path LIKE ? ORDER BY id DESC LIMIT 1'
|
|
2405
|
+
).get(filename, '%/' + filename);
|
|
2406
|
+
if (row) return row;
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
return null;
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
async function _ensureRelativeSymlink(linkPath, relativeTarget) {
|
|
2413
|
+
try {
|
|
2414
|
+
const stat = await fs.promises.lstat(linkPath);
|
|
2415
|
+
if (stat.isSymbolicLink()) {
|
|
2416
|
+
const current = await fs.promises.readlink(linkPath);
|
|
2417
|
+
if (current === relativeTarget) return;
|
|
2418
|
+
}
|
|
2419
|
+
await fs.promises.unlink(linkPath);
|
|
2420
|
+
} catch (err) {
|
|
2421
|
+
if (!err || err.code !== 'ENOENT') throw err;
|
|
2422
|
+
}
|
|
2423
|
+
await fs.promises.symlink(relativeTarget, linkPath);
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
async function recordSessionImageRefs(data = {}) {
|
|
2427
|
+
const sessionId = String(data.sessionId || data.session_id || '').trim();
|
|
2428
|
+
if (!sessionId) throw new Error('sessionId required');
|
|
2429
|
+
const refs = Array.isArray(data.images) ? data.images : Array.isArray(data.attachments) ? data.attachments : [];
|
|
2430
|
+
const sessionSlug = _sanitizeSessionImageSegment(sessionId, 'session');
|
|
2431
|
+
const timestampSlug = _sessionImageTimestampSlug(data.submittedAt || data.submitted_at || Date.now());
|
|
2432
|
+
const promptHash = _sanitizeSessionImageSegment(
|
|
2433
|
+
data.promptHash || data.prompt_hash || _sessionImagePromptHash(data.promptText || data.prompt_text || '', [sessionId, timestampSlug]),
|
|
2434
|
+
'prompt'
|
|
2435
|
+
).slice(0, 32);
|
|
2436
|
+
const refDir = path.join(SESSION_IMAGES_DIR, sessionSlug);
|
|
2437
|
+
await fs.promises.mkdir(refDir, { recursive: true });
|
|
2438
|
+
|
|
2439
|
+
const links = [];
|
|
2440
|
+
const skipped = [];
|
|
2441
|
+
const seenTargets = new Set();
|
|
2442
|
+
let index = 0;
|
|
2443
|
+
for (const ref of refs) {
|
|
2444
|
+
const row = _imageRowForReference(ref);
|
|
2445
|
+
if (!row || !row.file_path) {
|
|
2446
|
+
skipped.push({ label: ref && ref.label || '', reason: 'image_not_found' });
|
|
2447
|
+
continue;
|
|
2448
|
+
}
|
|
2449
|
+
const targetPath = path.resolve(row.file_path);
|
|
2450
|
+
if (!_pathInsideDir(targetPath, DEFAULT_IMAGES_DIR)) {
|
|
2451
|
+
skipped.push({ label: ref && ref.label || '', reason: 'outside_image_store' });
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
if (!fs.existsSync(targetPath)) {
|
|
2455
|
+
skipped.push({ label: ref && ref.label || '', reason: 'file_missing' });
|
|
2456
|
+
continue;
|
|
2457
|
+
}
|
|
2458
|
+
if (seenTargets.has(targetPath)) continue;
|
|
2459
|
+
seenTargets.add(targetPath);
|
|
2460
|
+
index += 1;
|
|
2461
|
+
const ext = path.extname(targetPath) || path.extname(row.filename || '') || '.png';
|
|
2462
|
+
const linkName = `${timestampSlug}-${promptHash}-image-${String(index).padStart(2, '0')}${ext}`;
|
|
2463
|
+
const linkPath = path.join(refDir, linkName);
|
|
2464
|
+
const relativeTarget = path.relative(refDir, targetPath);
|
|
2465
|
+
await _ensureRelativeSymlink(linkPath, relativeTarget);
|
|
2466
|
+
links.push({
|
|
2467
|
+
label: ref && ref.label || `[Image #${index}]`,
|
|
2468
|
+
image_id: row.id || null,
|
|
2469
|
+
filename: row.filename || path.basename(targetPath),
|
|
2470
|
+
mime_type: row.mime_type || ref && ref.mimeType || 'image/png',
|
|
2471
|
+
link: linkName,
|
|
2472
|
+
target: relativeTarget,
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
const manifestName = `${timestampSlug}-${promptHash}.json`;
|
|
2477
|
+
const manifest = {
|
|
2478
|
+
session_id: sessionId,
|
|
2479
|
+
submitted_at: timestampSlug,
|
|
2480
|
+
prompt_hash: promptHash,
|
|
2481
|
+
prompt_text_hash: _sessionImagePromptHash(data.promptText || data.prompt_text || '', [sessionId, timestampSlug]),
|
|
2482
|
+
created_at: new Date().toISOString(),
|
|
2483
|
+
links,
|
|
2484
|
+
skipped,
|
|
2485
|
+
};
|
|
2486
|
+
await fs.promises.writeFile(path.join(refDir, manifestName), JSON.stringify(manifest, null, 2) + '\n');
|
|
2487
|
+
return {
|
|
2488
|
+
sessionId,
|
|
2489
|
+
refDir,
|
|
2490
|
+
manifest: manifestName,
|
|
2491
|
+
links,
|
|
2492
|
+
skipped,
|
|
2493
|
+
};
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2278
2496
|
// --- Chains ---
|
|
2279
2497
|
function createChain({ name, description }) {
|
|
2280
2498
|
const result = getDb().prepare('INSERT INTO chains (name, description) VALUES (?, ?)').run(name, description || '');
|
|
@@ -3268,6 +3486,95 @@ function getInsightsData(includeInternal) {
|
|
|
3268
3486
|
};
|
|
3269
3487
|
}
|
|
3270
3488
|
|
|
3489
|
+
// --- Session Model Preferences ---
|
|
3490
|
+
function _cleanSessionModelPreferenceInput(input = {}) {
|
|
3491
|
+
const clean = (value, max) => String(value || '').trim().slice(0, max);
|
|
3492
|
+
return {
|
|
3493
|
+
ctmSessionId: clean(input.ctmSessionId || input.ctm_session_id || input.sessionId || input.id, 160),
|
|
3494
|
+
agentType: clean(input.agentType || input.agent_type || 'walle', 60) || 'walle',
|
|
3495
|
+
providerType: clean(input.providerType || input.provider_type || input.model_provider || input.provider, 120),
|
|
3496
|
+
providerId: clean(input.providerId || input.provider_id, 160),
|
|
3497
|
+
modelId: clean(input.modelId || input.model_id || input.model, 256),
|
|
3498
|
+
registryId: clean(input.registryId || input.registry_id || input.model_registry_id, 256),
|
|
3499
|
+
scope: clean(input.scope || 'session', 40) || 'session',
|
|
3500
|
+
source: clean(input.source || 'user', 80) || 'user',
|
|
3501
|
+
pinned: input.pinned === false || input.pinned === 0 ? 0 : 1,
|
|
3502
|
+
};
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
function upsertSessionModelPreference(input = {}) {
|
|
3506
|
+
const item = _cleanSessionModelPreferenceInput(input);
|
|
3507
|
+
if (!item.ctmSessionId) throw new Error('ctmSessionId is required');
|
|
3508
|
+
if (!item.modelId) {
|
|
3509
|
+
clearSessionModelPreference(item.ctmSessionId);
|
|
3510
|
+
return null;
|
|
3511
|
+
}
|
|
3512
|
+
const d = getDb();
|
|
3513
|
+
d.prepare(
|
|
3514
|
+
"INSERT OR IGNORE INTO ctm_sessions (id, provider, updated_at) VALUES (?, ?, datetime('now'))"
|
|
3515
|
+
).run(item.ctmSessionId, item.agentType || 'walle');
|
|
3516
|
+
d.prepare(`
|
|
3517
|
+
INSERT INTO session_model_preferences (
|
|
3518
|
+
ctm_session_id, agent_type, provider_type, provider_id, model_id,
|
|
3519
|
+
registry_id, scope, source, pinned, updated_at
|
|
3520
|
+
)
|
|
3521
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
3522
|
+
ON CONFLICT(ctm_session_id) DO UPDATE SET
|
|
3523
|
+
agent_type = excluded.agent_type,
|
|
3524
|
+
provider_type = excluded.provider_type,
|
|
3525
|
+
provider_id = excluded.provider_id,
|
|
3526
|
+
model_id = excluded.model_id,
|
|
3527
|
+
registry_id = excluded.registry_id,
|
|
3528
|
+
scope = excluded.scope,
|
|
3529
|
+
source = excluded.source,
|
|
3530
|
+
pinned = excluded.pinned,
|
|
3531
|
+
updated_at = excluded.updated_at
|
|
3532
|
+
`).run(
|
|
3533
|
+
item.ctmSessionId, item.agentType, item.providerType, item.providerId,
|
|
3534
|
+
item.modelId, item.registryId, item.scope, item.source, item.pinned
|
|
3535
|
+
);
|
|
3536
|
+
flushWal();
|
|
3537
|
+
return getSessionModelPreference(item.ctmSessionId);
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
function getSessionModelPreference(ctmSessionId) {
|
|
3541
|
+
const id = String(ctmSessionId || '').trim();
|
|
3542
|
+
if (!id) return null;
|
|
3543
|
+
const row = getDb().prepare(
|
|
3544
|
+
'SELECT * FROM session_model_preferences WHERE ctm_session_id = ?'
|
|
3545
|
+
).get(id);
|
|
3546
|
+
if (!row) return null;
|
|
3547
|
+
return {
|
|
3548
|
+
ctm_session_id: row.ctm_session_id,
|
|
3549
|
+
ctmSessionId: row.ctm_session_id,
|
|
3550
|
+
agent_type: row.agent_type || 'walle',
|
|
3551
|
+
agentType: row.agent_type || 'walle',
|
|
3552
|
+
provider_type: row.provider_type || '',
|
|
3553
|
+
providerType: row.provider_type || '',
|
|
3554
|
+
provider_id: row.provider_id || '',
|
|
3555
|
+
providerId: row.provider_id || '',
|
|
3556
|
+
model_id: row.model_id || '',
|
|
3557
|
+
modelId: row.model_id || '',
|
|
3558
|
+
registry_id: row.registry_id || '',
|
|
3559
|
+
registryId: row.registry_id || '',
|
|
3560
|
+
scope: row.scope || 'session',
|
|
3561
|
+
source: row.source || 'user',
|
|
3562
|
+
pinned: row.pinned !== 0,
|
|
3563
|
+
created_at: row.created_at || '',
|
|
3564
|
+
updated_at: row.updated_at || '',
|
|
3565
|
+
};
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
function clearSessionModelPreference(ctmSessionId) {
|
|
3569
|
+
const id = String(ctmSessionId || '').trim();
|
|
3570
|
+
if (!id) return 0;
|
|
3571
|
+
const changes = getDb().prepare(
|
|
3572
|
+
'DELETE FROM session_model_preferences WHERE ctm_session_id = ?'
|
|
3573
|
+
).run(id).changes || 0;
|
|
3574
|
+
if (changes) flushWal();
|
|
3575
|
+
return changes;
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3271
3578
|
// --- Startup Tasks (crash-safe session restore) ---
|
|
3272
3579
|
const CTM_INSTANCE_ID = `${require('os').hostname()}:${process.pid}:${Date.now()}`;
|
|
3273
3580
|
|
|
@@ -4436,6 +4743,7 @@ function upsertSession(id, data, opts) {
|
|
|
4436
4743
|
const normalizedIdentity = _normalizeAgentSessionIdentity(data);
|
|
4437
4744
|
const agentId = normalizedIdentity.agentSessionId;
|
|
4438
4745
|
const providerResumeId = normalizedIdentity.providerResumeId;
|
|
4746
|
+
const provider = normalizedIdentity.provider || data.provider || '';
|
|
4439
4747
|
function throwCrossTabClaim(existingCtmSessionId) {
|
|
4440
4748
|
const err = new Error(
|
|
4441
4749
|
`agent_session_id ${agentId} is already claimed by ctm_session ${existingCtmSessionId} (refusing cross-tab claim for ${id})`
|
|
@@ -4448,7 +4756,7 @@ function upsertSession(id, data, opts) {
|
|
|
4448
4756
|
}
|
|
4449
4757
|
const ctmParams = {
|
|
4450
4758
|
id,
|
|
4451
|
-
provider
|
|
4759
|
+
provider,
|
|
4452
4760
|
project_path: data.projectPath || '',
|
|
4453
4761
|
cwd: data.cwd || '',
|
|
4454
4762
|
title: data.title || '',
|
|
@@ -4463,7 +4771,7 @@ function upsertSession(id, data, opts) {
|
|
|
4463
4771
|
const agentParams = (agentId && agentId !== '__CLEAR__') ? {
|
|
4464
4772
|
agent_session_id: agentId,
|
|
4465
4773
|
ctm_session_id: id,
|
|
4466
|
-
provider
|
|
4774
|
+
provider,
|
|
4467
4775
|
provider_resume_id: providerResumeId || '',
|
|
4468
4776
|
project_path: data.projectPath || '',
|
|
4469
4777
|
jsonl_path: data.jsonlPath || '',
|
|
@@ -4809,6 +5117,161 @@ function repairCodexSessionMetadataFromConversations({ dryRun = false, limit = 5
|
|
|
4809
5117
|
};
|
|
4810
5118
|
}
|
|
4811
5119
|
|
|
5120
|
+
function _newerTimestamp(a, b) {
|
|
5121
|
+
const aMs = Date.parse(String(a || ''));
|
|
5122
|
+
const bMs = Date.parse(String(b || ''));
|
|
5123
|
+
if (!Number.isFinite(aMs)) return b || '';
|
|
5124
|
+
if (!Number.isFinite(bMs)) return a || '';
|
|
5125
|
+
return aMs >= bMs ? a : b;
|
|
5126
|
+
}
|
|
5127
|
+
|
|
5128
|
+
function _mergeCodexAgentRow(existing, stale, canonicalId) {
|
|
5129
|
+
const resumeAlias = _codexResumeAlias(existing?.provider_resume_id, canonicalId)
|
|
5130
|
+
|| _codexResumeAlias(stale?.provider_resume_id, canonicalId)
|
|
5131
|
+
|| _codexResumeAlias(stale?.agent_session_id, canonicalId);
|
|
5132
|
+
return {
|
|
5133
|
+
ctm_session_id: existing?.ctm_session_id || stale?.ctm_session_id || '',
|
|
5134
|
+
provider_resume_id: resumeAlias,
|
|
5135
|
+
project_path: existing?.project_path || stale?.project_path || '',
|
|
5136
|
+
jsonl_path: existing?.jsonl_path || stale?.jsonl_path || '',
|
|
5137
|
+
first_message: existing?.first_message || stale?.first_message || '',
|
|
5138
|
+
file_size: Math.max(Number(existing?.file_size || 0), Number(stale?.file_size || 0)),
|
|
5139
|
+
modified_at: _newerTimestamp(existing?.modified_at, stale?.modified_at),
|
|
5140
|
+
hostname: existing?.hostname || stale?.hostname || '',
|
|
5141
|
+
model: existing?.model || stale?.model || '',
|
|
5142
|
+
git_branch: existing?.git_branch || stale?.git_branch || '',
|
|
5143
|
+
user_msg_count: Math.max(Number(existing?.user_msg_count || 0), Number(stale?.user_msg_count || 0)),
|
|
5144
|
+
slug: existing?.slug || stale?.slug || '',
|
|
5145
|
+
};
|
|
5146
|
+
}
|
|
5147
|
+
|
|
5148
|
+
function repairCodexRolloutAgentIdentities({ dryRun = false, limit = 1000 } = {}) {
|
|
5149
|
+
const d = getDb();
|
|
5150
|
+
const candidates = d.prepare(`
|
|
5151
|
+
SELECT *
|
|
5152
|
+
FROM agent_sessions
|
|
5153
|
+
WHERE jsonl_path LIKE '%rollout-%.jsonl%'
|
|
5154
|
+
ORDER BY
|
|
5155
|
+
CASE
|
|
5156
|
+
WHEN COALESCE(provider, '') != 'codex' THEN 0
|
|
5157
|
+
WHEN agent_session_id LIKE 'rollout-%' THEN 0
|
|
5158
|
+
WHEN provider_resume_id LIKE 'rollout-%' THEN 0
|
|
5159
|
+
ELSE 1
|
|
5160
|
+
END,
|
|
5161
|
+
updated_at DESC,
|
|
5162
|
+
modified_at DESC
|
|
5163
|
+
LIMIT ?
|
|
5164
|
+
`).all(Math.max(1, Number(limit) || 1000));
|
|
5165
|
+
|
|
5166
|
+
const repairs = [];
|
|
5167
|
+
for (const row of candidates) {
|
|
5168
|
+
const canonicalId = _codexFileAgentSessionId(row.jsonl_path);
|
|
5169
|
+
if (!canonicalId) continue;
|
|
5170
|
+
const provider = normalizeAgentType(row.provider || '') || String(row.provider || '').toLowerCase();
|
|
5171
|
+
if (row.agent_session_id === canonicalId && provider === 'codex') continue;
|
|
5172
|
+
repairs.push({ ...row, canonicalId, provider });
|
|
5173
|
+
}
|
|
5174
|
+
|
|
5175
|
+
if (!dryRun && repairs.length > 0) {
|
|
5176
|
+
const selectAgent = d.prepare('SELECT * FROM agent_sessions WHERE agent_session_id = ?');
|
|
5177
|
+
const updateCanonical = d.prepare(`
|
|
5178
|
+
UPDATE agent_sessions SET
|
|
5179
|
+
ctm_session_id = COALESCE(NULLIF(?, ''), ctm_session_id),
|
|
5180
|
+
provider = 'codex',
|
|
5181
|
+
provider_resume_id = ?,
|
|
5182
|
+
project_path = COALESCE(NULLIF(?, ''), project_path),
|
|
5183
|
+
jsonl_path = COALESCE(NULLIF(?, ''), jsonl_path),
|
|
5184
|
+
first_message = COALESCE(NULLIF(?, ''), first_message),
|
|
5185
|
+
file_size = CASE WHEN ? > COALESCE(file_size, 0) THEN ? ELSE COALESCE(file_size, 0) END,
|
|
5186
|
+
modified_at = COALESCE(NULLIF(?, ''), modified_at),
|
|
5187
|
+
hostname = COALESCE(NULLIF(?, ''), hostname),
|
|
5188
|
+
model = COALESCE(NULLIF(?, ''), model),
|
|
5189
|
+
git_branch = COALESCE(NULLIF(?, ''), git_branch),
|
|
5190
|
+
user_msg_count = CASE WHEN ? > COALESCE(user_msg_count, 0) THEN ? ELSE COALESCE(user_msg_count, 0) END,
|
|
5191
|
+
slug = COALESCE(NULLIF(?, ''), slug),
|
|
5192
|
+
updated_at = datetime('now')
|
|
5193
|
+
WHERE agent_session_id = ?
|
|
5194
|
+
`);
|
|
5195
|
+
const updateInPlace = d.prepare(`
|
|
5196
|
+
UPDATE agent_sessions SET
|
|
5197
|
+
agent_session_id = ?,
|
|
5198
|
+
provider = 'codex',
|
|
5199
|
+
provider_resume_id = ?,
|
|
5200
|
+
updated_at = datetime('now')
|
|
5201
|
+
WHERE agent_session_id = ?
|
|
5202
|
+
`);
|
|
5203
|
+
const deleteStale = d.prepare('DELETE FROM agent_sessions WHERE agent_session_id = ?');
|
|
5204
|
+
const updateCtmProvider = d.prepare(`
|
|
5205
|
+
UPDATE ctm_sessions
|
|
5206
|
+
SET provider = 'codex', updated_at = datetime('now')
|
|
5207
|
+
WHERE id = ? AND COALESCE(provider, '') != 'codex'
|
|
5208
|
+
`);
|
|
5209
|
+
|
|
5210
|
+
const txn = d.transaction(() => {
|
|
5211
|
+
for (const row of repairs) {
|
|
5212
|
+
const canonical = selectAgent.get(row.canonicalId);
|
|
5213
|
+
if (canonical && canonical.agent_session_id !== row.agent_session_id) {
|
|
5214
|
+
const merged = _mergeCodexAgentRow(canonical, row, row.canonicalId);
|
|
5215
|
+
updateCanonical.run(
|
|
5216
|
+
merged.ctm_session_id,
|
|
5217
|
+
merged.provider_resume_id,
|
|
5218
|
+
merged.project_path,
|
|
5219
|
+
merged.jsonl_path,
|
|
5220
|
+
merged.first_message,
|
|
5221
|
+
merged.file_size,
|
|
5222
|
+
merged.file_size,
|
|
5223
|
+
merged.modified_at,
|
|
5224
|
+
merged.hostname,
|
|
5225
|
+
merged.model,
|
|
5226
|
+
merged.git_branch,
|
|
5227
|
+
merged.user_msg_count,
|
|
5228
|
+
merged.user_msg_count,
|
|
5229
|
+
merged.slug,
|
|
5230
|
+
row.canonicalId
|
|
5231
|
+
);
|
|
5232
|
+
deleteStale.run(row.agent_session_id);
|
|
5233
|
+
} else if (row.agent_session_id !== row.canonicalId) {
|
|
5234
|
+
const resumeAlias = _codexResumeAlias(row.provider_resume_id, row.canonicalId)
|
|
5235
|
+
|| _codexResumeAlias(row.agent_session_id, row.canonicalId);
|
|
5236
|
+
updateInPlace.run(row.canonicalId, resumeAlias, row.agent_session_id);
|
|
5237
|
+
} else {
|
|
5238
|
+
updateCanonical.run(
|
|
5239
|
+
row.ctm_session_id || '',
|
|
5240
|
+
_codexResumeAlias(row.provider_resume_id, row.canonicalId),
|
|
5241
|
+
row.project_path || '',
|
|
5242
|
+
row.jsonl_path || '',
|
|
5243
|
+
row.first_message || '',
|
|
5244
|
+
Number(row.file_size || 0),
|
|
5245
|
+
Number(row.file_size || 0),
|
|
5246
|
+
row.modified_at || '',
|
|
5247
|
+
row.hostname || '',
|
|
5248
|
+
row.model || '',
|
|
5249
|
+
row.git_branch || '',
|
|
5250
|
+
Number(row.user_msg_count || 0),
|
|
5251
|
+
Number(row.user_msg_count || 0),
|
|
5252
|
+
row.slug || '',
|
|
5253
|
+
row.canonicalId
|
|
5254
|
+
);
|
|
5255
|
+
}
|
|
5256
|
+
if (row.ctm_session_id) updateCtmProvider.run(row.ctm_session_id);
|
|
5257
|
+
}
|
|
5258
|
+
});
|
|
5259
|
+
txn();
|
|
5260
|
+
flushWal();
|
|
5261
|
+
}
|
|
5262
|
+
|
|
5263
|
+
return {
|
|
5264
|
+
checked: candidates.length,
|
|
5265
|
+
repaired: repairs.length,
|
|
5266
|
+
rows: repairs.map(row => ({
|
|
5267
|
+
ctm_session_id: row.ctm_session_id,
|
|
5268
|
+
from_agent_session_id: row.agent_session_id,
|
|
5269
|
+
to_agent_session_id: row.canonicalId,
|
|
5270
|
+
provider: row.provider,
|
|
5271
|
+
})),
|
|
5272
|
+
};
|
|
5273
|
+
}
|
|
5274
|
+
|
|
4812
5275
|
/**
|
|
4813
5276
|
* Get all session data in old-compatible format (for apiRecentSessions).
|
|
4814
5277
|
* Returns merged ctm_sessions + agent_sessions rows.
|
|
@@ -4988,7 +5451,7 @@ module.exports = {
|
|
|
4988
5451
|
createPrompt, updatePrompt, getPrompt, listPrompts, listChildPrompts, setPromptParent, createGroupFromPrompts, deletePrompt, duplicatePrompt, reorderPrompts,
|
|
4989
5452
|
getPromptVersions, restorePromptVersion,
|
|
4990
5453
|
createFolder, listFolders, updateFolder, deleteFolder, reorderFolders,
|
|
4991
|
-
saveImage, getImage, updateImageAnnotations, listImages, deleteImage,
|
|
5454
|
+
saveImage, getImage, updateImageAnnotations, listImages, deleteImage, recordSessionImageRefs,
|
|
4992
5455
|
createChain, getChain, listChains, updateChain, deleteChain,
|
|
4993
5456
|
listPermissionRules, upsertPermissionRule, deletePermissionRule,
|
|
4994
5457
|
listPermissionLog, addPermissionLog,
|
|
@@ -5013,6 +5476,7 @@ module.exports = {
|
|
|
5013
5476
|
replaceInsightRecommendations, listInsightRecommendations,
|
|
5014
5477
|
startAnalysisRun, completeAnalysisRun, getLastAnalysisRun,
|
|
5015
5478
|
getInsightsData,
|
|
5479
|
+
upsertSessionModelPreference, getSessionModelPreference, clearSessionModelPreference,
|
|
5016
5480
|
addStartupTask, updateStartupTaskLabel, updateStartupTaskClaudeSession, updateStartupTaskAgentSession, updateStartupTaskBranch, updateStartupTaskCwd, removeStartupTask, markStartupTaskExited, heartbeatStartupTasks, getStartupTask, listStartupTasks, clearStartupTasks,
|
|
5017
5481
|
createSessionWithStartupTask,
|
|
5018
5482
|
appendScrollbackBatch, cleanupScrollbackChunkSeqIntegrity, getNextScrollbackSeq, loadScrollback, loadScrollbackTail, clearScrollback,
|
|
@@ -5020,7 +5484,7 @@ module.exports = {
|
|
|
5020
5484
|
listHooks, getEnabledHooks, createHook, deleteHook, toggleHook,
|
|
5021
5485
|
upsertAnalysisFts, backfillFts, ftsSearchAnalyses, invalidateFtsRowidMap,
|
|
5022
5486
|
extractSessionMessages, extractSessionMessagesAsync, replaceSessionMessages, backfillSessionMessages, backfillSessionMessagesAsync, ftsSearchMessages,
|
|
5023
|
-
DEFAULT_IMAGES_DIR, BACKUP_DIR,
|
|
5487
|
+
DEFAULT_IMAGES_DIR, SESSION_IMAGES_DIR, BACKUP_DIR,
|
|
5024
5488
|
// Workspaces (Phase 13)
|
|
5025
5489
|
listWorkspaces, createWorkspace, updateWorkspace, deleteWorkspace,
|
|
5026
5490
|
addSessionToWorkspace, removeSessionFromWorkspace, getWorkspaceSessions, getSessionWorkspace,
|
|
@@ -5034,7 +5498,7 @@ module.exports = {
|
|
|
5034
5498
|
// CTM Sessions + Agent Sessions (v1 schema)
|
|
5035
5499
|
getSessionIdentity, getLatestAgentSessionForCtm, getSessionConversationSourceIds, resolveSession, upsertSession, setSessionStar, getStarredSessionIds,
|
|
5036
5500
|
setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getAllSessionsData,
|
|
5037
|
-
repairCodexSessionMetadataFromConversations,
|
|
5501
|
+
repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities,
|
|
5038
5502
|
getAgentSessions, getAgentSession, deleteCtmSession,
|
|
5039
5503
|
// Schema version
|
|
5040
5504
|
getSchemaVersion,
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# CTM App Update Refresh Protocol
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
CTM is a long-lived browser app. A user can keep several CTM tabs open while
|
|
6
|
+
`create-walle update` replaces CTM and Wall-E code, restarts the server, and
|
|
7
|
+
serves newer HTML, CSS, and JavaScript. Existing browser tabs may reconnect to
|
|
8
|
+
the new server while still executing the old client bundle.
|
|
9
|
+
|
|
10
|
+
That is unsafe in both directions:
|
|
11
|
+
|
|
12
|
+
- silently keeping old code leaves users on stale UX after an update;
|
|
13
|
+
- blindly reloading every tab can destroy active terminal input, queue drafts,
|
|
14
|
+
screenshot edits, or mobile composer text.
|
|
15
|
+
|
|
16
|
+
## Design
|
|
17
|
+
|
|
18
|
+
The server is the source of truth for the installed application identity. Each
|
|
19
|
+
WebSocket `hello` includes an app identity:
|
|
20
|
+
|
|
21
|
+
- `version`: the installed create-walle version;
|
|
22
|
+
- `components`: CTM and Wall-E package versions;
|
|
23
|
+
- `buildId`: a stable hash of key shipped files and package versions.
|
|
24
|
+
|
|
25
|
+
`buildId` is recomputed from file stats when version info is requested. It must
|
|
26
|
+
not be process-cached because update installers can replace static assets before
|
|
27
|
+
or during a server restart.
|
|
28
|
+
|
|
29
|
+
The client records the first app identity it sees for the current document. On
|
|
30
|
+
later WebSocket reconnects, if the server identity changes, the page is running
|
|
31
|
+
old code and must refresh before continuing normal operation.
|
|
32
|
+
|
|
33
|
+
## UX Contract
|
|
34
|
+
|
|
35
|
+
When a changed app identity is detected:
|
|
36
|
+
|
|
37
|
+
1. Broadcast `reload-required` to same-origin CTM tabs using `BroadcastChannel`.
|
|
38
|
+
2. If the current tab is idle, reload immediately.
|
|
39
|
+
3. If the current tab has active user work, show a sticky reload-required banner
|
|
40
|
+
and do not steal focus.
|
|
41
|
+
4. The banner remains until the user clicks **Reload now** or the page is
|
|
42
|
+
refreshed.
|
|
43
|
+
|
|
44
|
+
Active user work includes focused xterm.js terminals, editable inputs,
|
|
45
|
+
contenteditable composers, open modals, open queue panel, and screenshot editor
|
|
46
|
+
state.
|
|
47
|
+
|
|
48
|
+
## Mobile/PWA
|
|
49
|
+
|
|
50
|
+
The mobile service worker already uses a network-first shell strategy and
|
|
51
|
+
activates new workers. That only updates future navigations; a currently open
|
|
52
|
+
mobile document still needs the same runtime identity handshake. Mobile uses the
|
|
53
|
+
same `hello` comparison and shows a compact refresh banner when a composer or
|
|
54
|
+
detail view is active.
|
|
55
|
+
|
|
56
|
+
## Non-Goals
|
|
57
|
+
|
|
58
|
+
- No hard reload while a user is typing.
|
|
59
|
+
- No reload prompt for ordinary CTM restarts when the app identity is unchanged.
|
|
60
|
+
- No dependency on service workers for desktop refresh behavior.
|
|
61
|
+
|
|
62
|
+
## Verification
|
|
63
|
+
|
|
64
|
+
Focused render tests should cover:
|
|
65
|
+
|
|
66
|
+
- an idle desktop tab auto-refreshes on server identity change;
|
|
67
|
+
- a focused terminal desktop tab shows the reload banner without losing focus;
|
|
68
|
+
- another open tab receives the reload-required event through `BroadcastChannel`;
|
|
69
|
+
- mobile shows the refresh banner instead of silently keeping stale code.
|