open-research-protocol 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/AGENT_INTEGRATION.md +50 -0
  2. package/README.md +273 -144
  3. package/bin/orp.js +14 -1
  4. package/cli/orp.py +14846 -9925
  5. package/docs/AGENT_LOOP.md +13 -0
  6. package/docs/AGENT_MODES.md +79 -0
  7. package/docs/CANONICAL_CLI_BOUNDARY.md +15 -0
  8. package/docs/EXCHANGE.md +94 -0
  9. package/docs/LAUNCH_KIT.md +107 -0
  10. package/docs/ORP_HOSTED_WORKSPACE_CONTRACT.md +295 -0
  11. package/docs/ORP_PUBLIC_LAUNCH_CHECKLIST.md +5 -0
  12. package/docs/START_HERE.md +567 -0
  13. package/package.json +4 -2
  14. package/packages/lifeops-orp/README.md +67 -0
  15. package/packages/lifeops-orp/package.json +48 -0
  16. package/packages/lifeops-orp/src/index.d.ts +106 -0
  17. package/packages/lifeops-orp/src/index.js +7 -0
  18. package/packages/lifeops-orp/src/mapping.js +309 -0
  19. package/packages/lifeops-orp/src/workspace.js +108 -0
  20. package/packages/lifeops-orp/test/orp.test.js +187 -0
  21. package/packages/orp-workspace-launcher/README.md +82 -0
  22. package/packages/orp-workspace-launcher/package.json +39 -0
  23. package/packages/orp-workspace-launcher/src/commands.js +77 -0
  24. package/packages/orp-workspace-launcher/src/core-plan.js +506 -0
  25. package/packages/orp-workspace-launcher/src/hosted-state.js +208 -0
  26. package/packages/orp-workspace-launcher/src/index.js +82 -0
  27. package/packages/orp-workspace-launcher/src/ledger.js +745 -0
  28. package/packages/orp-workspace-launcher/src/list.js +488 -0
  29. package/packages/orp-workspace-launcher/src/orp-command.js +126 -0
  30. package/packages/orp-workspace-launcher/src/orp.js +912 -0
  31. package/packages/orp-workspace-launcher/src/registry.js +558 -0
  32. package/packages/orp-workspace-launcher/src/slot.js +188 -0
  33. package/packages/orp-workspace-launcher/src/sync.js +363 -0
  34. package/packages/orp-workspace-launcher/src/tabs.js +166 -0
  35. package/packages/orp-workspace-launcher/test/commands.test.js +164 -0
  36. package/packages/orp-workspace-launcher/test/core-plan.test.js +253 -0
  37. package/packages/orp-workspace-launcher/test/fixtures/smoke-notes.txt +2 -0
  38. package/packages/orp-workspace-launcher/test/fixtures/workspace-manifest.json +17 -0
  39. package/packages/orp-workspace-launcher/test/ledger.test.js +244 -0
  40. package/packages/orp-workspace-launcher/test/list.test.js +299 -0
  41. package/packages/orp-workspace-launcher/test/orp-command.test.js +44 -0
  42. package/packages/orp-workspace-launcher/test/orp.test.js +224 -0
  43. package/packages/orp-workspace-launcher/test/tabs.test.js +168 -0
  44. package/scripts/orp-kernel-agent-pilot.py +10 -1
  45. package/scripts/orp-kernel-agent-replication.py +10 -1
  46. package/scripts/orp-kernel-canonical-continuation.py +10 -1
  47. package/scripts/orp-kernel-continuation-pilot.py +10 -1
  48. package/scripts/render-terminal-demo.py +416 -0
  49. package/spec/v1/exchange-report.schema.json +105 -0
  50. package/spec/v1/hosted-workspace-event.schema.json +102 -0
  51. package/spec/v1/hosted-workspace.schema.json +332 -0
  52. package/spec/v1/workspace.schema.json +108 -0
@@ -0,0 +1,82 @@
1
+ # orp-workspace-launcher
2
+
3
+ Manage a durable ORP workspace ledger of project paths plus saved `codex resume ...` or `claude --resume ...` commands.
4
+
5
+ The package no longer automates iTerm or Terminal.app. The workspace ledger is the source of truth, and you use Terminal however you want.
6
+
7
+ ## Core flow
8
+
9
+ Create a local workspace ledger with no hosted account required:
10
+
11
+ ```bash
12
+ orp workspace create main-cody-1
13
+ orp workspace create research-lab --path /Volumes/Code_2TB/code/research-lab --resume-tool claude --resume-session-id 469d99b2-2997-42bf-a8f5-3812c808ef29
14
+ ```
15
+
16
+ Inspect the saved ledger:
17
+
18
+ ```bash
19
+ orp workspace ledger main
20
+ orp workspace tabs main
21
+ ```
22
+
23
+ Print copyable recovery commands:
24
+
25
+ ```bash
26
+ orp workspace tabs main
27
+ orp workspace tabs main --json
28
+ ```
29
+
30
+ Add a new saved tab manually:
31
+
32
+ ```bash
33
+ orp workspace ledger add main --path /Volumes/Code_2TB/code/frg-site --resume-command "codex resume 019d348d-5031-78e1-9840-a66deaac33ae"
34
+ orp workspace add-tab main --path /Volumes/Code_2TB/code/anthropic-lab --resume-tool claude --resume-session-id claude-456
35
+ ```
36
+
37
+ Remove a saved tab manually:
38
+
39
+ ```bash
40
+ orp workspace ledger remove main --path /Volumes/Code_2TB/code/frg-site
41
+ orp workspace remove-tab main --resume-session-id claude-456 --resume-tool claude
42
+ ```
43
+
44
+ Work directly with a local manifest file:
45
+
46
+ ```bash
47
+ orp workspace add-tab --workspace-file ./workspace.json --path /Volumes/Code_2TB/code/orp
48
+ orp workspace tabs --workspace-file ./workspace.json
49
+ orp workspace tabs --workspace-file ./workspace.json --json
50
+ ```
51
+
52
+ Sync a local manifest back to the hosted canonical workspace:
53
+
54
+ ```bash
55
+ orp workspace sync main --workspace-file ./workspace.json
56
+ orp workspace sync main --workspace-file ./workspace.json --dry-run
57
+ ```
58
+
59
+ List saved workspaces:
60
+
61
+ ```bash
62
+ orp workspace list
63
+ orp workspace slot list
64
+ orp workspace slot set main main-cody-1
65
+ orp workspace slot set offhand research-lab
66
+ ```
67
+
68
+ ## Options
69
+
70
+ - `--json`: print agent-friendly JSON
71
+ - `--notes-file <path>`: read a local notes file instead of ORP
72
+ - `--hosted-workspace-id <id>`: read or update a first-class hosted workspace instead of an idea-backed bridge
73
+ - `--workspace-file <path>`: read or update a structured workspace manifest JSON file
74
+ - `--base-url <url>`: override the ORP hosted base URL
75
+ - `--orp-command <path-or-command>`: override the ORP CLI binary used to fetch hosted idea JSON
76
+ - `--path <absolute-path>`: add or match a saved project path
77
+ - `--title <text>`: set or match a saved tab title
78
+ - `--resume-command <text>`: save or match an exact `codex resume ...` or `claude --resume ...` command
79
+ - `--resume-tool <codex|claude>`: build or narrow the resume command by tool
80
+ - `--resume-session-id <id>`: build or match a specific session id
81
+ - `--index <n>`: remove a saved tab by 1-based index
82
+ - `--all`: remove every matching saved tab
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "orp-workspace-launcher",
3
+ "version": "0.1.0",
4
+ "description": "Manage ORP workspace ledgers with saved paths and Codex or Claude resume commands.",
5
+ "author": "Fractal Research Group <cody@frg.earth>",
6
+ "homepage": "https://github.com/SproutSeeds/orp/tree/main/packages/orp-workspace-launcher",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/SproutSeeds/orp.git",
10
+ "directory": "packages/orp-workspace-launcher"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/SproutSeeds/orp/issues"
14
+ },
15
+ "type": "module",
16
+ "exports": {
17
+ ".": "./src/index.js"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "src",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test"
26
+ },
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "keywords": [
31
+ "codex",
32
+ "open-research-protocol",
33
+ "orp",
34
+ "workspace",
35
+ "ledger",
36
+ "claude"
37
+ ],
38
+ "license": "MIT"
39
+ }
@@ -0,0 +1,77 @@
1
+ import process from "node:process";
2
+
3
+ import {
4
+ buildWorkspaceTabsReport,
5
+ parseWorkspaceTabsArgs,
6
+ summarizeWorkspaceTabs,
7
+ } from "./tabs.js";
8
+ import { loadWorkspaceSource } from "./orp.js";
9
+ import { parseWorkspaceSource } from "./core-plan.js";
10
+
11
+ export function parseWorkspaceCommandsArgs(argv = []) {
12
+ return parseWorkspaceTabsArgs(argv);
13
+ }
14
+
15
+ export function buildWorkspaceCommandsReport(source, parsed, options = {}) {
16
+ const report = buildWorkspaceTabsReport(source, parsed, options);
17
+ return {
18
+ ...report,
19
+ commandCount: report.tabCount,
20
+ };
21
+ }
22
+
23
+ export function summarizeWorkspaceCommands(report) {
24
+ return summarizeWorkspaceTabs({
25
+ ...report,
26
+ tabCount: report.commandCount ?? report.tabCount,
27
+ });
28
+ }
29
+
30
+ function printWorkspaceCommandsHelp() {
31
+ console.log(`ORP workspace commands
32
+
33
+ Usage:
34
+ orp workspace commands <name-or-id> [--json]
35
+ orp workspace commands --hosted-workspace-id <workspace-id> [--json]
36
+ orp workspace commands --notes-file <path> [--json]
37
+ orp workspace commands --workspace-file <path> [--json]
38
+
39
+ Options:
40
+ --json Print saved recovery commands as JSON
41
+ --hosted-workspace-id <id> Read a first-class hosted workspace instead of an idea
42
+ --notes-file <path> Read a local notes file instead of ORP
43
+ --workspace-file <path> Read a structured workspace manifest JSON file
44
+ --base-url <url> Override the ORP hosted base URL
45
+ --orp-command <cmd> Override the ORP CLI executable used for hosted fetches
46
+ -h, --help Show this help text
47
+
48
+ Notes:
49
+ - This is now a compatibility alias for \`orp workspace tabs ...\`.
50
+ - Use \`orp workspace tabs ...\` as the main read command for saved paths plus copyable resume lines.
51
+ - The selector can be \`main\`, \`offhand\`, a hosted idea id, a hosted workspace id, a local workspace id, or a saved workspace title/slug.
52
+ `);
53
+ }
54
+
55
+ export async function runWorkspaceCommands(argv = process.argv.slice(2)) {
56
+ const options = parseWorkspaceCommandsArgs(argv);
57
+ if (options.help) {
58
+ printWorkspaceCommandsHelp();
59
+ return 0;
60
+ }
61
+
62
+ const source = await loadWorkspaceSource(options);
63
+ const parsed = parseWorkspaceSource(source);
64
+ const report = buildWorkspaceCommandsReport(source, parsed, options);
65
+
66
+ if (report.commandCount === 0) {
67
+ throw new Error("No saved workspace commands were found in the provided workspace source.");
68
+ }
69
+
70
+ if (options.json) {
71
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
72
+ return 0;
73
+ }
74
+
75
+ process.stdout.write(`${summarizeWorkspaceCommands(report)}\n`);
76
+ return 0;
77
+ }
@@ -0,0 +1,506 @@
1
+ import path from "node:path";
2
+
3
+ export const WORKSPACE_SCHEMA_VERSION = "1";
4
+
5
+ const SUPPORTED_RESUME_TOOLS = new Set(["codex", "claude"]);
6
+ const CODEX_RESUME_PATTERN = /^\s*codex\s+resume\s+([^\s]+)(?:\s+.*)?$/i;
7
+ const CLAUDE_LEGACY_RESUME_PATTERN = /^\s*claude\s+resume\s+([^\s]+)(?:\s+.*)?$/i;
8
+ const CLAUDE_FLAG_RESUME_PATTERN = /^\s*claude\s+(?:--resume|-r)(?:=|\s+)([^\s]+)(?:\s+.*)?$/i;
9
+ const STRUCTURED_WORKSPACE_PATTERN = /```orp-workspace\s*([\s\S]*?)```/i;
10
+
11
+ function partitionOnColon(value) {
12
+ const index = value.indexOf(":");
13
+ if (index === -1) {
14
+ return [value, ""];
15
+ }
16
+ return [value.slice(0, index), value.slice(index + 1)];
17
+ }
18
+
19
+ function shellQuote(value) {
20
+ const text = String(value);
21
+ if (text.length === 0) {
22
+ return "''";
23
+ }
24
+ return `'${text.replace(/'/g, `'\"'\"'`)}'`;
25
+ }
26
+
27
+ function normalizeDisplayPath(value) {
28
+ const trimmed = String(value).trim();
29
+ if (trimmed === "/") {
30
+ return trimmed;
31
+ }
32
+ return trimmed.replace(/\/+$/, "");
33
+ }
34
+
35
+ function hashText(value) {
36
+ let hash = 0;
37
+ for (let index = 0; index < value.length; index += 1) {
38
+ hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
39
+ }
40
+ return hash.toString(36);
41
+ }
42
+
43
+ function slugify(value) {
44
+ const normalized = String(value || "")
45
+ .trim()
46
+ .toLowerCase()
47
+ .replace(/[^a-z0-9]+/g, "-")
48
+ .replace(/^-+|-+$/g, "");
49
+ return normalized || "workspace";
50
+ }
51
+
52
+ function validateAbsolutePath(value, label) {
53
+ if (typeof value !== "string" || !value.trim().startsWith("/")) {
54
+ throw new Error(`${label} must be an absolute path`);
55
+ }
56
+ return value.trim();
57
+ }
58
+
59
+ function normalizeOptionalString(value) {
60
+ if (value == null) {
61
+ return null;
62
+ }
63
+ const trimmed = String(value).trim();
64
+ return trimmed.length > 0 ? trimmed : null;
65
+ }
66
+
67
+ function normalizeResumeTool(value) {
68
+ const trimmed = normalizeOptionalString(value)?.toLowerCase() || null;
69
+ return trimmed && SUPPORTED_RESUME_TOOLS.has(trimmed) ? trimmed : null;
70
+ }
71
+
72
+ export function buildCanonicalResumeCommand(tool, sessionId) {
73
+ const resumeTool = normalizeResumeTool(tool);
74
+ const resumeSessionId = normalizeOptionalString(sessionId);
75
+ if (!resumeTool || !resumeSessionId) {
76
+ return null;
77
+ }
78
+ if (resumeTool === "claude") {
79
+ return `claude --resume ${resumeSessionId}`;
80
+ }
81
+ return `${resumeTool} resume ${resumeSessionId}`;
82
+ }
83
+
84
+ export function parseResumeCommandText(value) {
85
+ const trimmed = normalizeOptionalString(value);
86
+ if (!trimmed) {
87
+ return {
88
+ resumeCommand: null,
89
+ resumeTool: null,
90
+ resumeSessionId: null,
91
+ };
92
+ }
93
+
94
+ const codexMatch = trimmed.match(CODEX_RESUME_PATTERN);
95
+ if (codexMatch) {
96
+ return {
97
+ resumeCommand: trimmed,
98
+ resumeTool: "codex",
99
+ resumeSessionId: normalizeOptionalString(codexMatch[1]),
100
+ };
101
+ }
102
+
103
+ const claudeFlagMatch = trimmed.match(CLAUDE_FLAG_RESUME_PATTERN);
104
+ if (claudeFlagMatch) {
105
+ return {
106
+ resumeCommand: trimmed,
107
+ resumeTool: "claude",
108
+ resumeSessionId: normalizeOptionalString(claudeFlagMatch[1]),
109
+ };
110
+ }
111
+
112
+ const claudeLegacyMatch = trimmed.match(CLAUDE_LEGACY_RESUME_PATTERN);
113
+ if (!claudeLegacyMatch) {
114
+ return {
115
+ resumeCommand: null,
116
+ resumeTool: null,
117
+ resumeSessionId: null,
118
+ };
119
+ }
120
+
121
+ return {
122
+ resumeCommand: trimmed,
123
+ resumeTool: "claude",
124
+ resumeSessionId: normalizeOptionalString(claudeLegacyMatch[1]),
125
+ };
126
+ }
127
+
128
+ export function resolveResumeMetadata(raw = {}) {
129
+ const parsedCommand = parseResumeCommandText(raw.resumeCommand ?? raw.remainder);
130
+ const explicitTool = normalizeResumeTool(raw.resumeTool);
131
+ const explicitResumeSessionId = normalizeOptionalString(raw.resumeSessionId ?? raw.sessionId);
132
+ const legacyCodexSessionId = normalizeOptionalString(raw.codexSessionId);
133
+ const legacyClaudeSessionId = normalizeOptionalString(raw.claudeSessionId);
134
+
135
+ const resumeTool =
136
+ parsedCommand.resumeTool ||
137
+ explicitTool ||
138
+ (explicitResumeSessionId ? "codex" : null) ||
139
+ (legacyCodexSessionId ? "codex" : null) ||
140
+ (legacyClaudeSessionId ? "claude" : null);
141
+
142
+ const resumeSessionId =
143
+ parsedCommand.resumeSessionId ||
144
+ explicitResumeSessionId ||
145
+ (resumeTool === "codex" ? legacyCodexSessionId : null) ||
146
+ (resumeTool === "claude" ? legacyClaudeSessionId : null) ||
147
+ legacyCodexSessionId ||
148
+ legacyClaudeSessionId ||
149
+ null;
150
+
151
+ const resumeCommand =
152
+ parsedCommand.resumeCommand ||
153
+ buildCanonicalResumeCommand(resumeTool, resumeSessionId);
154
+
155
+ return {
156
+ resumeCommand,
157
+ resumeTool,
158
+ resumeSessionId,
159
+ };
160
+ }
161
+
162
+ export function getResumeCommand(entry) {
163
+ return resolveResumeMetadata(entry).resumeCommand;
164
+ }
165
+
166
+ function normalizeOptionalPositiveInteger(value, label) {
167
+ if (value == null || value === "") {
168
+ return null;
169
+ }
170
+ const parsed = Number.parseInt(String(value), 10);
171
+ if (!Number.isInteger(parsed) || parsed < 1) {
172
+ throw new Error(`${label} must be a positive integer`);
173
+ }
174
+ return parsed;
175
+ }
176
+
177
+ function normalizeOptionalNonNegativeInteger(value, label) {
178
+ if (value == null || value === "") {
179
+ return null;
180
+ }
181
+ const parsed = Number.parseInt(String(value), 10);
182
+ if (!Number.isInteger(parsed) || parsed < 0) {
183
+ throw new Error(`${label} must be a non-negative integer`);
184
+ }
185
+ return parsed;
186
+ }
187
+
188
+ function normalizeOptionalPositiveNumber(value, label) {
189
+ if (value == null || value === "") {
190
+ return null;
191
+ }
192
+ const parsed = Number(String(value));
193
+ if (!Number.isFinite(parsed) || parsed <= 0) {
194
+ throw new Error(`${label} must be a positive number`);
195
+ }
196
+ return parsed;
197
+ }
198
+
199
+ function normalizeCaptureMetadata(rawCapture) {
200
+ if (rawCapture == null) {
201
+ return null;
202
+ }
203
+ if (!rawCapture || typeof rawCapture !== "object" || Array.isArray(rawCapture)) {
204
+ throw new Error("workspace manifest capture metadata must be an object");
205
+ }
206
+
207
+ const capture = Object.fromEntries(
208
+ Object.entries({
209
+ sourceApp: normalizeOptionalString(rawCapture.sourceApp),
210
+ mode: normalizeOptionalString(rawCapture.mode),
211
+ host: normalizeOptionalString(rawCapture.host),
212
+ windowId: normalizeOptionalPositiveInteger(rawCapture.windowId, "workspace manifest capture.windowId"),
213
+ windowIndex: normalizeOptionalPositiveInteger(rawCapture.windowIndex, "workspace manifest capture.windowIndex"),
214
+ tabCount: normalizeOptionalNonNegativeInteger(rawCapture.tabCount, "workspace manifest capture.tabCount"),
215
+ capturedAt: normalizeOptionalString(rawCapture.capturedAt),
216
+ trackingStartedAt: normalizeOptionalString(rawCapture.trackingStartedAt),
217
+ pollSeconds: normalizeOptionalPositiveNumber(rawCapture.pollSeconds, "workspace manifest capture.pollSeconds"),
218
+ }).filter(([, value]) => value != null),
219
+ );
220
+
221
+ return Object.keys(capture).length > 0 ? capture : null;
222
+ }
223
+
224
+ function normalizeStructuredTab(rawTab, index) {
225
+ if (!rawTab || typeof rawTab !== "object" || Array.isArray(rawTab)) {
226
+ throw new Error(`workspace tab ${index + 1} must be an object`);
227
+ }
228
+
229
+ const pathValue = validateAbsolutePath(rawTab.path, `workspace tab ${index + 1} path`);
230
+ const title = normalizeOptionalString(rawTab.title);
231
+ const resume = resolveResumeMetadata(rawTab);
232
+ const tmuxSessionName = normalizeOptionalString(rawTab.tmuxSessionName);
233
+
234
+ return {
235
+ lineNumber: index + 1,
236
+ rawLine: JSON.stringify(rawTab),
237
+ path: pathValue,
238
+ remainder: resume.resumeCommand || "",
239
+ sessionId: resume.resumeSessionId,
240
+ resumeCommand: resume.resumeCommand,
241
+ resumeTool: resume.resumeTool,
242
+ title,
243
+ tmuxSessionName,
244
+ };
245
+ }
246
+
247
+ export function normalizeWorkspaceManifest(rawManifest) {
248
+ if (!rawManifest || typeof rawManifest !== "object" || Array.isArray(rawManifest)) {
249
+ throw new Error("workspace manifest must be a JSON object");
250
+ }
251
+
252
+ const version = String(rawManifest.version ?? WORKSPACE_SCHEMA_VERSION);
253
+ if (version !== WORKSPACE_SCHEMA_VERSION) {
254
+ throw new Error(`unsupported workspace manifest version: ${version}`);
255
+ }
256
+
257
+ if (!Array.isArray(rawManifest.tabs) || rawManifest.tabs.length === 0) {
258
+ throw new Error("workspace manifest must include a non-empty tabs array");
259
+ }
260
+
261
+ const tabs = rawManifest.tabs.map((tab, index) => normalizeStructuredTab(tab, index));
262
+ return {
263
+ version,
264
+ workspaceId: normalizeOptionalString(rawManifest.workspaceId),
265
+ title: normalizeOptionalString(rawManifest.title),
266
+ tmuxPrefix: normalizeOptionalString(rawManifest.tmuxPrefix),
267
+ capture: normalizeCaptureMetadata(rawManifest.capture),
268
+ tabs,
269
+ };
270
+ }
271
+
272
+ export function extractStructuredWorkspaceFromNotes(notes) {
273
+ const match = String(notes || "").match(STRUCTURED_WORKSPACE_PATTERN);
274
+ if (!match) {
275
+ return null;
276
+ }
277
+
278
+ let parsed;
279
+ try {
280
+ parsed = JSON.parse(match[1]);
281
+ } catch (error) {
282
+ throw new Error(
283
+ `failed to parse \`\`\`orp-workspace\`\`\` JSON: ${error instanceof Error ? error.message : String(error)}`,
284
+ );
285
+ }
286
+
287
+ return normalizeWorkspaceManifest(parsed);
288
+ }
289
+
290
+ export function deriveBaseTitle(entry) {
291
+ if (entry.title && String(entry.title).trim().length > 0) {
292
+ return String(entry.title).trim();
293
+ }
294
+ const normalized = normalizeDisplayPath(entry.path);
295
+ return path.basename(normalized) || normalized;
296
+ }
297
+
298
+ export function deriveTmuxSessionName(entry, options = {}) {
299
+ if (entry.tmuxSessionName && String(entry.tmuxSessionName).trim().length > 0) {
300
+ return String(entry.tmuxSessionName).trim();
301
+ }
302
+
303
+ const prefix = (options.tmuxPrefix || "orp").trim() || "orp";
304
+ const base = deriveBaseTitle(entry)
305
+ .toLowerCase()
306
+ .replace(/[^a-z0-9]+/g, "-")
307
+ .replace(/^-+|-+$/g, "") || "workspace";
308
+ const entropy = hashText(`${entry.path}:${getResumeCommand(entry) || entry.sessionId || ""}:${entry.lineNumber || 0}`).slice(
309
+ 0,
310
+ 6,
311
+ );
312
+ return `${prefix}-${base}-${entropy}`.slice(0, 64);
313
+ }
314
+
315
+ export function deriveWorkspaceId(source, parsed) {
316
+ if (parsed.manifest?.workspaceId) {
317
+ return parsed.manifest.workspaceId;
318
+ }
319
+
320
+ if (source.sourceType === "hosted-idea" && source.idea?.id) {
321
+ return `idea-${source.idea.id}`;
322
+ }
323
+
324
+ if (source.sourceType === "workspace-file" && source.sourcePath) {
325
+ return `file-${slugify(path.basename(source.sourcePath, path.extname(source.sourcePath)))}-${hashText(source.sourcePath).slice(0, 6)}`;
326
+ }
327
+
328
+ if (source.sourcePath) {
329
+ return `file-${hashText(source.sourcePath).slice(0, 8)}`;
330
+ }
331
+
332
+ return `workspace-${hashText(`${source.sourceType}:${source.sourceLabel}`).slice(0, 8)}`;
333
+ }
334
+
335
+ export function parseCorePlanNotes(notes) {
336
+ const entries = [];
337
+ const skipped = [];
338
+
339
+ for (const [index, rawLine] of String(notes || "").split(/\r?\n/).entries()) {
340
+ const lineNumber = index + 1;
341
+ const line = rawLine.trim();
342
+ if (!line) {
343
+ continue;
344
+ }
345
+ if (!line.startsWith("/")) {
346
+ skipped.push({ lineNumber, rawLine });
347
+ continue;
348
+ }
349
+
350
+ const [rawPath, rawRemainder] = partitionOnColon(line);
351
+ const workspacePath = rawPath.trim();
352
+ const remainder = rawRemainder.trim();
353
+ const resume = resolveResumeMetadata({ resumeCommand: remainder });
354
+
355
+ entries.push({
356
+ lineNumber,
357
+ rawLine,
358
+ path: workspacePath,
359
+ remainder,
360
+ sessionId: resume.resumeSessionId,
361
+ resumeCommand: resume.resumeCommand,
362
+ resumeTool: resume.resumeTool,
363
+ title: null,
364
+ tmuxSessionName: null,
365
+ });
366
+ }
367
+
368
+ return { entries, skipped, manifest: null, parseMode: "notes" };
369
+ }
370
+
371
+ export function parseWorkspaceSource(source) {
372
+ if (source.workspaceManifest) {
373
+ const manifest = normalizeWorkspaceManifest(source.workspaceManifest);
374
+ return {
375
+ entries: manifest.tabs,
376
+ skipped: [],
377
+ manifest,
378
+ parseMode: "manifest",
379
+ };
380
+ }
381
+
382
+ const structuredManifest = extractStructuredWorkspaceFromNotes(source.notes || "");
383
+ if (structuredManifest) {
384
+ return {
385
+ entries: structuredManifest.tabs,
386
+ skipped: [],
387
+ manifest: structuredManifest,
388
+ parseMode: "manifest",
389
+ };
390
+ }
391
+
392
+ return parseCorePlanNotes(source.notes || "");
393
+ }
394
+
395
+ export function buildDirectCommand(entry, options = {}) {
396
+ const commands = [`cd ${shellQuote(entry.path)}`];
397
+ const resumeCommand = options.resume !== false ? getResumeCommand(entry) : null;
398
+ if (resumeCommand) {
399
+ commands.push(resumeCommand);
400
+ }
401
+ return commands.join(" && ");
402
+ }
403
+
404
+ export function buildTmuxPresentationCommands(entry, options = {}) {
405
+ const sessionName = options.sessionName || deriveTmuxSessionName(entry, options);
406
+ const quotedSession = shellQuote(sessionName);
407
+ const targetWindow = `${quotedSession}:0`;
408
+ const title = options.displayTitle || entry.displayTitle || deriveBaseTitle(entry);
409
+
410
+ return [
411
+ `tmux set-option -t ${quotedSession} set-titles on`,
412
+ `tmux set-option -t ${quotedSession} set-titles-string ${shellQuote(title)}`,
413
+ `tmux set-window-option -t ${targetWindow} automatic-rename off`,
414
+ `tmux set-window-option -t ${targetWindow} allow-rename off`,
415
+ `tmux rename-window -t ${targetWindow} ${shellQuote(title)}`,
416
+ ];
417
+ }
418
+
419
+ export function buildTmuxCommand(entry, options = {}) {
420
+ const sessionName = options.sessionName || deriveTmuxSessionName(entry, options);
421
+ const sessionNameQuoted = shellQuote(sessionName);
422
+ const loginShell = options.loginShell || process.env.SHELL || "/bin/zsh";
423
+ const bootstrapCommand = `cd ${shellQuote(entry.path)} && exec ${shellQuote(loginShell)} -l`;
424
+ const presentationCommands = buildTmuxPresentationCommands(entry, { ...options, sessionName });
425
+ const createParts = [
426
+ `tmux new-session -d -s ${sessionNameQuoted} ${shellQuote(bootstrapCommand)}`,
427
+ ...presentationCommands,
428
+ ];
429
+
430
+ const resumeCommand = options.resume !== false ? getResumeCommand(entry) : null;
431
+ if (resumeCommand) {
432
+ createParts.push(
433
+ `tmux send-keys -t ${sessionNameQuoted} ${shellQuote(resumeCommand)} C-m`,
434
+ );
435
+ }
436
+
437
+ return [
438
+ `if tmux has-session -t ${sessionNameQuoted} 2>/dev/null; then`,
439
+ `${presentationCommands.join(" && ")} && tmux attach-session -t ${sessionNameQuoted};`,
440
+ "else",
441
+ `${createParts.join(" && ")} && tmux attach-session -t ${sessionNameQuoted};`,
442
+ "fi",
443
+ ].join(" ");
444
+ }
445
+
446
+ export function buildLaunchPlan(entries, options = {}) {
447
+ const titleCounts = new Map();
448
+
449
+ return entries.map((entry) => {
450
+ const baseTitle = deriveBaseTitle(entry);
451
+ const occurrence = (titleCounts.get(baseTitle) || 0) + 1;
452
+ titleCounts.set(baseTitle, occurrence);
453
+
454
+ const title = occurrence === 1 ? baseTitle : `${baseTitle} (${occurrence})`;
455
+ const sessionName = options.tmux ? deriveTmuxSessionName(entry, options) : null;
456
+ // iTerm already appends "(tmux)" when a tab is backed by a tmux session,
457
+ // so the title we push into tmux should stay clean.
458
+ const displayTitle = title;
459
+ const command = options.tmux
460
+ ? buildTmuxCommand(entry, { ...options, sessionName, displayTitle })
461
+ : buildDirectCommand(entry, options);
462
+
463
+ return {
464
+ ...entry,
465
+ title,
466
+ displayTitle,
467
+ sessionName,
468
+ command,
469
+ mode: options.tmux ? "tmux" : "direct",
470
+ };
471
+ });
472
+ }
473
+
474
+ export function summarizeLaunchPlan(plan) {
475
+ const terminalLabel = plan.terminalApp === "terminal" ? "Terminal.app" : "iTerm";
476
+ const lines = [
477
+ `Source: ${plan.sourceLabel}`,
478
+ `Workspace ID: ${plan.workspaceId}`,
479
+ `Tabs: ${plan.tabs.length}`,
480
+ `Mode: ${plan.tmux ? `${terminalLabel} + tmux` : `${terminalLabel} direct`}`,
481
+ `Parse mode: ${plan.parseMode}`,
482
+ "",
483
+ ];
484
+
485
+ for (const [index, tab] of plan.tabs.entries()) {
486
+ lines.push(`${String(index + 1).padStart(2, "0")}. ${tab.title}`);
487
+ lines.push(` path: ${tab.path}`);
488
+ if (tab.resumeCommand) {
489
+ lines.push(` resume: ${tab.resumeCommand}`);
490
+ }
491
+ if (tab.sessionName) {
492
+ lines.push(` tmux: ${tab.sessionName}`);
493
+ }
494
+ lines.push(` command: ${tab.command}`);
495
+ }
496
+
497
+ if (plan.skipped.length > 0) {
498
+ lines.push("");
499
+ lines.push("Skipped lines:");
500
+ for (const skipped of plan.skipped) {
501
+ lines.push(` line ${skipped.lineNumber}: ${skipped.rawLine}`);
502
+ }
503
+ }
504
+
505
+ return lines.join("\n");
506
+ }