create-walle 0.9.21 → 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 +348 -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/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 +83 -0
- package/template/claude-task-manager/lib/mobile-auth-api.js +14 -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-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 +539 -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 +42 -2
- package/template/claude-task-manager/public/js/stream-view.js +20 -1
- package/template/claude-task-manager/public/js/walle.js +314 -18
- package/template/claude-task-manager/public/m/app.css +789 -11
- package/template/claude-task-manager/public/m/app.js +1070 -67
- package/template/claude-task-manager/public/m/claim.html +9 -2
- package/template/claude-task-manager/public/m/index.html +17 -10
- package/template/claude-task-manager/public/m/sw.js +1 -1
- package/template/claude-task-manager/server.js +365 -95
- package/template/claude-task-manager/session-integrity.js +4 -0
- 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 +85 -0
- package/template/wall-e/coding-orchestrator.js +106 -12
- 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/website/index.html +10 -10
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# create-walle
|
|
2
2
|
|
|
3
|
-
Set up **CTM + Wall-E** in one command — a browser-based command center for AI coding agents, remote phone access, review workflows, and a personal AI agent that builds a searchable second brain from your work life.
|
|
3
|
+
Set up **CTM + Wall-E** in one command — a browser-based command center for AI coding agents, remote phone and tablet access, review workflows, and a personal AI agent that builds a searchable second brain from your work life.
|
|
4
4
|
|
|
5
5
|
## What You Get
|
|
6
6
|
|
|
@@ -12,7 +12,7 @@ A web dashboard for running and managing AI coding sessions across multiple prov
|
|
|
12
12
|
- **Prompt Editor** — Save, version, and organize prompts with folders, tags, chains, templates, and AI search
|
|
13
13
|
- **Task Queue** — Queue prompts for sequential execution with auto-advance when the agent finishes, or step through manually
|
|
14
14
|
- **Approval Workflows** — Auto-approve tool-use requests based on learned rules; uncertain cases escalate to you
|
|
15
|
-
- **Remote Phone Access** — Pair your phone with a QR code and use a
|
|
15
|
+
- **Remote Phone & Tablet Access** — Pair your phone or tablet with a QR code and use a responsive CTM UI over Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote, with live prompts and model controls
|
|
16
16
|
- **Code & Doc Review** — Review git diffs and Markdown docs side by side, add anchored comments, and send feedback into an agent session or queue
|
|
17
17
|
- **Model Registry** — Manage providers (Anthropic, OpenAI, Google, DeepSeek, Ollama, LM Studio, MLX, and CLI subscription providers), compare pricing, switch models per session
|
|
18
18
|
- **Session Insights** — Analyze patterns across sessions to optimize prompts and workflows
|
|
@@ -35,7 +35,7 @@ An always-on AI agent that learns from your Slack, email, calendar, and coding s
|
|
|
35
35
|
npx create-walle install ./walle
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
This copies the project, installs dependencies, auto-detects your name and timezone, and starts the server. Open **http://localhost:3456** to finish setup in the browser, then pair your phone from Setup if you want remote access.
|
|
38
|
+
This copies the project, installs dependencies, auto-detects your name and timezone, and starts the server. Open **http://localhost:3456** to finish setup in the browser, then pair your phone or tablet from Setup if you want remote access.
|
|
39
39
|
|
|
40
40
|
## Commands
|
|
41
41
|
|
|
@@ -62,7 +62,7 @@ On first launch, the browser setup page guides you through:
|
|
|
62
62
|
1. **Owner name** — auto-detected from `git config`
|
|
63
63
|
2. **API key** — enter manually, or click "Auto-detect" to find it from your shell environment, Claude Code OAuth, or corporate devbox
|
|
64
64
|
3. **Integrations** — connect Slack (OAuth), email and calendar auto-detected on macOS
|
|
65
|
-
4. **Remote phone access** — optional QR pairing with Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote, including
|
|
65
|
+
4. **Remote phone and tablet access** — optional QR pairing with Microsoft Dev Tunnels, Tailscale, Cloudflare Tunnel, or Walle Remote, including touch-friendly prompts and model controls
|
|
66
66
|
|
|
67
67
|
## Custom Port
|
|
68
68
|
|
|
@@ -78,7 +78,7 @@ Everything runs locally. CTM serves the dashboard, Wall-E runs as a background a
|
|
|
78
78
|
|
|
79
79
|
| Component | Default Port | What it does |
|
|
80
80
|
|-----------|-------------|--------------|
|
|
81
|
-
| CTM | 3456 | Dashboard, multi-agent terminal, prompt editor, queue, model registry, code/doc review, remote phone UI |
|
|
81
|
+
| CTM | 3456 | Dashboard, multi-agent terminal, prompt editor, queue, model registry, code/doc review, remote phone/tablet UI |
|
|
82
82
|
| Wall-E | 3457 | AI agent, brain database, skills engine, multi-model chat API |
|
|
83
83
|
|
|
84
84
|
## Links
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-walle",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "CTM + Wall-E \u2014 AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, remote phone access, code/doc review, and an agent that learns from Slack, email & calendar.",
|
|
3
|
+
"version": "0.9.22",
|
|
4
|
+
"description": "CTM + Wall-E \u2014 AI coding dashboard and personal digital twin agent. Multi-agent terminal for Claude Code, Codex, Gemini, Aider, OpenCode, and more, plus prompt editor, task queue, remote phone and tablet access, code/doc review, and an agent that learns from Slack, email & calendar.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-walle": "bin/create-walle.js"
|
|
7
7
|
},
|
|
@@ -143,6 +143,7 @@ function handlePromptApi(req, res, url) {
|
|
|
143
143
|
// --- Images ---
|
|
144
144
|
if (p === '/api/images/upload' && m === 'POST') return handleUploadImage(req, res, url);
|
|
145
145
|
if (p === '/api/mobile/attachments/upload' && m === 'POST') return handleUploadMobileAttachment(req, res, url);
|
|
146
|
+
if (p === '/api/session/image-refs' && m === 'POST') return handleSessionImageRefs(req, res);
|
|
146
147
|
if (p.match(/^\/api\/images\/\d+$/) && m === 'GET') return handleGetImage(req, res, url);
|
|
147
148
|
if (p.match(/^\/api\/images\/\d+\/annotations$/) && m === 'PUT') return handleUpdateAnnotations(req, res, url);
|
|
148
149
|
if (p.match(/^\/api\/images\/\d+$/) && m === 'DELETE') return handleDeleteImage(req, res, url);
|
|
@@ -677,6 +678,18 @@ async function handleUploadMobileAttachment(req, res, url) {
|
|
|
677
678
|
}
|
|
678
679
|
}
|
|
679
680
|
|
|
681
|
+
async function handleSessionImageRefs(req, res) {
|
|
682
|
+
try {
|
|
683
|
+
const body = await readBody(req, 256 * 1024);
|
|
684
|
+
const result = await db.recordSessionImageRefs(body || {});
|
|
685
|
+
const safeResult = { ...(result || {}) };
|
|
686
|
+
delete safeResult.refDir;
|
|
687
|
+
jsonResponse(res, 200, { ok: true, ...safeResult });
|
|
688
|
+
} catch (e) {
|
|
689
|
+
jsonResponse(res, 400, { ok: false, error: e.message });
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
680
693
|
function handleGetImage(req, res, url) {
|
|
681
694
|
const id = parseInt(url.pathname.split('/').pop());
|
|
682
695
|
const img = db.getImage(id);
|
|
@@ -140,7 +140,7 @@ function handleReviewApi(req, res, url) {
|
|
|
140
140
|
try {
|
|
141
141
|
const filePath = url.searchParams.get('path');
|
|
142
142
|
const line = url.searchParams.get('line') || 1;
|
|
143
|
-
const document = documentReview.readDocument(filePath, { line });
|
|
143
|
+
const document = documentReview.readDocument(filePath, { line, cwd: url.searchParams.get('cwd') || '' });
|
|
144
144
|
return jsonResponse(res, 200, { document });
|
|
145
145
|
} catch (e) {
|
|
146
146
|
return jsonResponse(res, e.statusCode || 500, { error: e.message });
|
|
@@ -150,7 +150,10 @@ function handleReviewApi(req, res, url) {
|
|
|
150
150
|
// POST /api/reviews/document-review - create/reuse a review record for one document
|
|
151
151
|
if (p === '/api/reviews/document-review' && m === 'POST') {
|
|
152
152
|
readBody(req).then(body => {
|
|
153
|
-
const document = documentReview.readDocument(body.path, {
|
|
153
|
+
const document = documentReview.readDocument(body.path, {
|
|
154
|
+
line: body.line || 1,
|
|
155
|
+
cwd: body.cwd || '',
|
|
156
|
+
});
|
|
154
157
|
const review = db.listReviews({
|
|
155
158
|
project_path: document.projectRoot,
|
|
156
159
|
review_type: 'doc',
|
|
@@ -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
|
|
|
@@ -2316,6 +2342,157 @@ function deleteImage(id) {
|
|
|
2316
2342
|
}
|
|
2317
2343
|
}
|
|
2318
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
|
+
|
|
2319
2496
|
// --- Chains ---
|
|
2320
2497
|
function createChain({ name, description }) {
|
|
2321
2498
|
const result = getDb().prepare('INSERT INTO chains (name, description) VALUES (?, ?)').run(name, description || '');
|
|
@@ -4566,6 +4743,7 @@ function upsertSession(id, data, opts) {
|
|
|
4566
4743
|
const normalizedIdentity = _normalizeAgentSessionIdentity(data);
|
|
4567
4744
|
const agentId = normalizedIdentity.agentSessionId;
|
|
4568
4745
|
const providerResumeId = normalizedIdentity.providerResumeId;
|
|
4746
|
+
const provider = normalizedIdentity.provider || data.provider || '';
|
|
4569
4747
|
function throwCrossTabClaim(existingCtmSessionId) {
|
|
4570
4748
|
const err = new Error(
|
|
4571
4749
|
`agent_session_id ${agentId} is already claimed by ctm_session ${existingCtmSessionId} (refusing cross-tab claim for ${id})`
|
|
@@ -4578,7 +4756,7 @@ function upsertSession(id, data, opts) {
|
|
|
4578
4756
|
}
|
|
4579
4757
|
const ctmParams = {
|
|
4580
4758
|
id,
|
|
4581
|
-
provider
|
|
4759
|
+
provider,
|
|
4582
4760
|
project_path: data.projectPath || '',
|
|
4583
4761
|
cwd: data.cwd || '',
|
|
4584
4762
|
title: data.title || '',
|
|
@@ -4593,7 +4771,7 @@ function upsertSession(id, data, opts) {
|
|
|
4593
4771
|
const agentParams = (agentId && agentId !== '__CLEAR__') ? {
|
|
4594
4772
|
agent_session_id: agentId,
|
|
4595
4773
|
ctm_session_id: id,
|
|
4596
|
-
provider
|
|
4774
|
+
provider,
|
|
4597
4775
|
provider_resume_id: providerResumeId || '',
|
|
4598
4776
|
project_path: data.projectPath || '',
|
|
4599
4777
|
jsonl_path: data.jsonlPath || '',
|
|
@@ -4939,6 +5117,161 @@ function repairCodexSessionMetadataFromConversations({ dryRun = false, limit = 5
|
|
|
4939
5117
|
};
|
|
4940
5118
|
}
|
|
4941
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
|
+
|
|
4942
5275
|
/**
|
|
4943
5276
|
* Get all session data in old-compatible format (for apiRecentSessions).
|
|
4944
5277
|
* Returns merged ctm_sessions + agent_sessions rows.
|
|
@@ -5118,7 +5451,7 @@ module.exports = {
|
|
|
5118
5451
|
createPrompt, updatePrompt, getPrompt, listPrompts, listChildPrompts, setPromptParent, createGroupFromPrompts, deletePrompt, duplicatePrompt, reorderPrompts,
|
|
5119
5452
|
getPromptVersions, restorePromptVersion,
|
|
5120
5453
|
createFolder, listFolders, updateFolder, deleteFolder, reorderFolders,
|
|
5121
|
-
saveImage, getImage, updateImageAnnotations, listImages, deleteImage,
|
|
5454
|
+
saveImage, getImage, updateImageAnnotations, listImages, deleteImage, recordSessionImageRefs,
|
|
5122
5455
|
createChain, getChain, listChains, updateChain, deleteChain,
|
|
5123
5456
|
listPermissionRules, upsertPermissionRule, deletePermissionRule,
|
|
5124
5457
|
listPermissionLog, addPermissionLog,
|
|
@@ -5151,7 +5484,7 @@ module.exports = {
|
|
|
5151
5484
|
listHooks, getEnabledHooks, createHook, deleteHook, toggleHook,
|
|
5152
5485
|
upsertAnalysisFts, backfillFts, ftsSearchAnalyses, invalidateFtsRowidMap,
|
|
5153
5486
|
extractSessionMessages, extractSessionMessagesAsync, replaceSessionMessages, backfillSessionMessages, backfillSessionMessagesAsync, ftsSearchMessages,
|
|
5154
|
-
DEFAULT_IMAGES_DIR, BACKUP_DIR,
|
|
5487
|
+
DEFAULT_IMAGES_DIR, SESSION_IMAGES_DIR, BACKUP_DIR,
|
|
5155
5488
|
// Workspaces (Phase 13)
|
|
5156
5489
|
listWorkspaces, createWorkspace, updateWorkspace, deleteWorkspace,
|
|
5157
5490
|
addSessionToWorkspace, removeSessionFromWorkspace, getWorkspaceSessions, getSessionWorkspace,
|
|
@@ -5165,7 +5498,7 @@ module.exports = {
|
|
|
5165
5498
|
// CTM Sessions + Agent Sessions (v1 schema)
|
|
5166
5499
|
getSessionIdentity, getLatestAgentSessionForCtm, getSessionConversationSourceIds, resolveSession, upsertSession, setSessionStar, getStarredSessionIds,
|
|
5167
5500
|
setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getAllSessionsData,
|
|
5168
|
-
repairCodexSessionMetadataFromConversations,
|
|
5501
|
+
repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities,
|
|
5169
5502
|
getAgentSessions, getAgentSession, deleteCtmSession,
|
|
5170
5503
|
// Schema version
|
|
5171
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.
|
|
@@ -12,6 +12,7 @@ CTM treats pasted screenshots as first-class attachments and shows compact promp
|
|
|
12
12
|
|
|
13
13
|
- Terminal sessions: paste events and the macOS `Ctrl+V` compatibility path upload images and insert the compact token, for example `[Image #1]`. The token is what the user sees and edits in both Claude and Codex sessions. `Cmd+Shift+V` on macOS and `Ctrl+Shift+V` elsewhere intentionally paste the raw local path reference. The terminal context menu can copy all pasted image paths or URLs for the active session.
|
|
14
14
|
- Terminal sessions backed by Claude Code or Codex use provider-native image handoff. CTM uploads and normalizes the browser clipboard image, then sends the normalized local image path as a bracketed paste to the PTY. The provider creates the real image attachment and renders its own compact `[Image #n]` chip, so the model receives image context while the terminal does not show raw paths.
|
|
15
|
+
- Terminal sessions backed by Claude Code or Codex also append durable image metadata when the prompt is submitted. CTM does not change the paste behavior: paste still inserts the compact token and keeps provider-native path handoff intact. At Enter/submit time, any unsent image attachments from the active input are appended once as `[Attached image: "..."]` or `[Attached images: "...", "..."]` before the newline reaches the PTY. This makes future Conversation/Review import able to resolve the image path even if the provider transcript only preserves terminal text.
|
|
15
16
|
- Terminal text paste also normalizes CTM image references. If the clipboard contains `[Image #1: "/path/to/file.png"]`, a raw local image path, or an `/api/images/file/...` URL, default paste deduplicates repeated references and hands each local image path to provider-native attachment paste when possible.
|
|
16
17
|
- Terminal image paste accepts browser clipboard files by `image/*` MIME type or by common image filename extension when the OS leaves the MIME type empty.
|
|
17
18
|
- Wall-E sessions: pasted images become structured message attachments. The composer inserts `[Image #n]`; sending preserves the compact token in the message text and sends image data separately.
|
|
@@ -20,3 +21,5 @@ CTM treats pasted screenshots as first-class attachments and shows compact promp
|
|
|
20
21
|
## Provider Contract
|
|
21
22
|
|
|
22
23
|
Compact tokens are UI references, not raw file paths. CTM keeps attachment metadata, normalized uploads, and copyable path/URL references beside the visible token. Provider adapters must not send only `[Image #n]` to a raw PTY. They should either send structured attachments, or, for Claude Code and Codex terminal sessions, trigger native image attachment by bracket-pasting the normalized local image path. Explicit path paste remains available for CLIs that require a visible local filesystem reference in the prompt text.
|
|
24
|
+
|
|
25
|
+
Submit-time metadata is a transcript durability layer, not a replacement for native provider image attachment. It must not run during paste, path-copy, or path-paste handling, and it must mark attachments as submitted or discarded so later prompts do not inherit stale image references.
|