@logicseek/hello-cc 0.1.4 → 0.1.6

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 (90) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/bin/hcc.mjs +2739 -6482
  3. package/docs/README.md +2 -0
  4. package/docs/README.zh-CN.md +3 -1
  5. package/docs/architecture-split-review.md +217 -0
  6. package/docs/architecture.md +385 -0
  7. package/docs/architecture.zh-CN.md +364 -0
  8. package/docs/commands.md +21 -9
  9. package/docs/commands.zh-CN.md +19 -9
  10. package/docs/layout-split-stack-audit.md +298 -0
  11. package/lib/automation.mjs +8 -0
  12. package/lib/cli-args.mjs +89 -0
  13. package/lib/cli-runtime.mjs +44 -0
  14. package/lib/coordination-state.mjs +218 -0
  15. package/lib/core/coordination/automation.mjs +222 -0
  16. package/lib/core/coordination/locks.mjs +46 -0
  17. package/lib/core/coordination/messages.mjs +116 -0
  18. package/lib/core/coordination/tasks.mjs +224 -0
  19. package/lib/core/coordination/teams.mjs +73 -0
  20. package/lib/core/coordination/timeline.mjs +187 -0
  21. package/lib/core/peers/bindings.mjs +83 -0
  22. package/lib/core/peers/format.mjs +13 -0
  23. package/lib/core/peers/identity.mjs +53 -0
  24. package/lib/core/peers/liveness.mjs +90 -0
  25. package/lib/core/peers/reconcile.mjs +238 -0
  26. package/lib/core/peers/session.mjs +14 -0
  27. package/lib/core/sessions/launch.mjs +89 -0
  28. package/lib/db/schema.mjs +324 -0
  29. package/lib/db/stores/peers.mjs +231 -0
  30. package/lib/db-schema.mjs +7 -0
  31. package/lib/errors.mjs +1 -0
  32. package/lib/format.mjs +39 -0
  33. package/lib/guidance.mjs +10 -2
  34. package/lib/handoff.mjs +26 -0
  35. package/lib/help.mjs +3 -0
  36. package/lib/integrations/hooks.mjs +171 -0
  37. package/lib/integrations/peers/identity.mjs +208 -0
  38. package/lib/integrations/peers/processes.mjs +151 -0
  39. package/lib/integrations/providers.mjs +282 -0
  40. package/lib/integrations/shims/script.mjs +384 -0
  41. package/lib/integrations/shims.mjs +106 -0
  42. package/lib/json-file.mjs +1 -0
  43. package/lib/locks.mjs +10 -0
  44. package/lib/messages.mjs +3 -0
  45. package/lib/package-meta.mjs +6 -24
  46. package/lib/peer-bindings.mjs +14 -0
  47. package/lib/peer-format.mjs +4 -0
  48. package/lib/peer-identity.mjs +15 -0
  49. package/lib/project-context.mjs +6 -0
  50. package/lib/project-registry.mjs +7 -0
  51. package/lib/provider-commands.mjs +13 -0
  52. package/lib/release/package-meta.mjs +31 -0
  53. package/lib/release/release-notes.mjs +40 -0
  54. package/lib/release-notes.mjs +6 -40
  55. package/lib/runtime/client.mjs +42 -0
  56. package/lib/runtime/paths.mjs +45 -0
  57. package/lib/runtime/project-context.mjs +32 -0
  58. package/lib/runtime/projects.mjs +76 -0
  59. package/lib/runtime/state.mjs +128 -0
  60. package/lib/runtime-client.mjs +3 -0
  61. package/lib/runtime-paths.mjs +11 -0
  62. package/lib/runtime-state.mjs +11 -0
  63. package/lib/session-launch.mjs +17 -0
  64. package/lib/setup.mjs +18 -765
  65. package/lib/shared/errors.mjs +7 -0
  66. package/lib/shared/json-file.mjs +14 -0
  67. package/lib/shell-path.mjs +69 -0
  68. package/lib/shim-script.mjs +1 -0
  69. package/lib/state-render.mjs +5 -0
  70. package/lib/task-cli.mjs +45 -0
  71. package/lib/task-liveness.mjs +8 -0
  72. package/lib/task-store.mjs +3 -0
  73. package/lib/team-planning.mjs +7 -0
  74. package/lib/terminal/tmux.mjs +372 -0
  75. package/lib/timeline.mjs +12 -0
  76. package/lib/tmux.mjs +18 -0
  77. package/lib/ui/help.mjs +419 -0
  78. package/lib/ui/state-render.mjs +61 -0
  79. package/lib/web/http.mjs +55 -0
  80. package/lib/web/peer-actions.mjs +270 -0
  81. package/lib/web/runtime.mjs +154 -0
  82. package/lib/web/ui-template.mjs +2055 -0
  83. package/lib/web-http.mjs +7 -0
  84. package/lib/web-peer-actions.mjs +3 -0
  85. package/lib/web-runtime.mjs +17 -0
  86. package/lib/web-ui-template.mjs +3 -0
  87. package/package.json +1 -1
  88. package/scripts/github-release.mjs +2 -2
  89. package/scripts/regression.mjs +2738 -22
  90. package/scripts/release-notes.mjs +2 -2
@@ -0,0 +1,7 @@
1
+ export class CliError extends Error {
2
+ constructor(code, message, extra = {}) {
3
+ super(message);
4
+ this.code = code;
5
+ this.extra = extra;
6
+ }
7
+ }
@@ -0,0 +1,14 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export function readJsonSafe(filePath) {
5
+ try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); }
6
+ catch { return null; }
7
+ }
8
+
9
+ export function writeJsonSafe(filePath, data) {
10
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
11
+ const tmpPath = filePath + '.tmp.' + process.pid;
12
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n');
13
+ fs.renameSync(tmpPath, filePath);
14
+ }
@@ -0,0 +1,69 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ export function pathEntryLine(shellType) {
6
+ if (shellType === 'fish') return 'set -gx PATH $HOME/.hcc-shims $PATH # hello-cc shims';
7
+ return '_hcc_shims="$HOME/.hcc-shims"; PATH="${PATH//:${_hcc_shims}:/:}"; PATH="${PATH#${_hcc_shims}:}"; PATH="${PATH%:${_hcc_shims}}"; export PATH="${_hcc_shims}:$PATH"; unset _hcc_shims # hello-cc shims';
8
+ }
9
+
10
+ export function removePathEntryLines(content) {
11
+ return String(content || '')
12
+ .split('\n')
13
+ .filter(line => !line.includes('.hcc-shims'))
14
+ .join('\n')
15
+ .replace(/\n{3,}/g, '\n\n')
16
+ .replace(/\s+$/, '');
17
+ }
18
+
19
+ export function insertPathEntry(content, shellType) {
20
+ const line = pathEntryLine(shellType);
21
+ const cleaned = removePathEntryLines(content);
22
+ if (shellType === 'fish') return `${cleaned}${cleaned ? '\n' : ''}${line}\n`;
23
+
24
+ const lines = cleaned ? cleaned.split('\n') : [];
25
+ let insertAt = 0;
26
+ if (lines[0]?.startsWith('#!')) insertAt = 1;
27
+ for (let i = insertAt; i < lines.length; i += 1) {
28
+ const text = lines[i].trim();
29
+ if (!text || text.startsWith('#')) continue;
30
+ insertAt = i;
31
+ break;
32
+ }
33
+ lines.splice(insertAt, 0, line);
34
+ const withEarlyEntry = lines.join('\n').replace(/\s+$/, '');
35
+ return `${withEarlyEntry}${withEarlyEntry ? '\n' : ''}${line}\n`;
36
+ }
37
+
38
+ export function detectShellRc() {
39
+ const shell = process.env.SHELL || '';
40
+ if (shell.includes('zsh')) return { path: path.join(os.homedir(), '.zshrc'), type: 'zsh' };
41
+ if (shell.includes('fish')) return { path: path.join(os.homedir(), '.config', 'fish', 'config.fish'), type: 'fish' };
42
+ return { path: path.join(os.homedir(), '.bashrc'), type: 'bash' };
43
+ }
44
+
45
+ export function installPathEntry() {
46
+ const { path: rcFile, type: shellType } = detectShellRc();
47
+ let content = '';
48
+ try { content = fs.readFileSync(rcFile, 'utf8'); } catch {}
49
+
50
+ const next = insertPathEntry(content, shellType);
51
+ const alreadyPresent = next === (content.endsWith('\n') ? content : `${content}\n`);
52
+ if (!alreadyPresent) fs.writeFileSync(rcFile, next);
53
+ return { rcFile, shellType, alreadyPresent };
54
+ }
55
+
56
+ export function uninstallPathEntry() {
57
+ const { path: rcFile } = detectShellRc();
58
+ try {
59
+ const content = fs.readFileSync(rcFile, 'utf8');
60
+ const filtered = content
61
+ .split('\n')
62
+ .filter(line => !line.includes('.hcc-shims'))
63
+ .join('\n');
64
+ fs.writeFileSync(rcFile, filtered);
65
+ return { rcFile };
66
+ } catch {
67
+ return { rcFile, error: 'could not modify shell rc' };
68
+ }
69
+ }
@@ -0,0 +1 @@
1
+ export { generateShim } from './integrations/shims/script.mjs';
@@ -0,0 +1,5 @@
1
+ export {
2
+ normalizeStateResources,
3
+ renderStateSummary,
4
+ renderStatusSummary
5
+ } from './ui/state-render.mjs';
@@ -0,0 +1,45 @@
1
+ import { intOpt } from './cli-args.mjs';
2
+ import { CliError } from './shared/errors.mjs';
3
+
4
+ export function parseTaskIds(opts) {
5
+ const values = [];
6
+ const addValue = (value) => {
7
+ if (Array.isArray(value)) {
8
+ for (const item of value) addValue(item);
9
+ return;
10
+ }
11
+ if (value === undefined || value === null || value === '') return;
12
+ for (const part of String(value).split(',')) {
13
+ const text = part.trim();
14
+ if (text) values.push(text);
15
+ }
16
+ };
17
+ addValue(opts.id);
18
+ addValue(opts.ids);
19
+ addValue(opts._ || []);
20
+ const seen = new Set();
21
+ const ids = [];
22
+ for (const value of values) {
23
+ if (!/^\d+$/.test(value)) throw new CliError('BAD_ARGS', `task id must be an integer: ${value}`);
24
+ const id = Number.parseInt(value, 10);
25
+ if (!seen.has(id)) {
26
+ seen.add(id);
27
+ ids.push(id);
28
+ }
29
+ }
30
+ if (!ids.length) throw new CliError('BAD_ARGS', 'Missing --id');
31
+ return ids;
32
+ }
33
+
34
+ export function positiveIntOpt(opts, key, fallback, { max = 50 } = {}) {
35
+ const value = intOpt(opts, key, fallback);
36
+ if (value < 1) throw new CliError('BAD_ARGS', `--${key} must be >= 1`);
37
+ if (value > max) throw new CliError('BAD_ARGS', `--${key} must be <= ${max}`);
38
+ return value;
39
+ }
40
+
41
+ export function taskRowsText(tasks, verb = 'claimed') {
42
+ const rows = Array.isArray(tasks) ? tasks : [tasks].filter(Boolean);
43
+ if (!rows.length) return 'no pending task';
44
+ return rows.map((task) => `${verb} task #${task.id}: ${task.title}`).join('\n');
45
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ annotateTasksWithLiveness,
3
+ formatOpenTaskLine,
4
+ summarizeTask,
5
+ taskOwnerLiveness,
6
+ taskOwnerStateText,
7
+ taskRelatedLocks
8
+ } from './core/peers/liveness.mjs';
@@ -0,0 +1,3 @@
1
+ export {
2
+ createTaskStore
3
+ } from './core/coordination/tasks.mjs';
@@ -0,0 +1,7 @@
1
+ export {
2
+ assignTeamWorkers,
3
+ expandTeamWorkers,
4
+ inferTeamItems,
5
+ parseTeamItems,
6
+ splitCsvList
7
+ } from './core/coordination/teams.mjs';
@@ -0,0 +1,372 @@
1
+ import fs from 'node:fs';
2
+ import process from 'node:process';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { CliError } from '../shared/errors.mjs';
5
+ import { shellQuoteArg } from '../format.mjs';
6
+ import {
7
+ LAUNCH_FINGERPRINT_ENV,
8
+ PROVIDER_STATE_ENV
9
+ } from '../core/sessions/launch.mjs';
10
+ import {
11
+ sanitizePeerPart,
12
+ shortHash
13
+ } from '../core/peers/format.mjs';
14
+
15
+ export function runTmux(args, opts = {}) {
16
+ const result = spawnSync('tmux', args, {
17
+ encoding: 'utf8',
18
+ stdio: ['pipe', 'pipe', 'pipe'],
19
+ input: opts.input || ''
20
+ });
21
+ if (result.error) {
22
+ throw new CliError('TMUX_ERROR', `tmux failed: ${result.error.message}`);
23
+ }
24
+ if (result.status !== 0) {
25
+ const message = (result.stderr || result.stdout || '').trim() || `tmux ${args.join(' ')} failed`;
26
+ throw new CliError('TMUX_ERROR', message);
27
+ }
28
+ return result.stdout || '';
29
+ }
30
+
31
+ function tmuxInstallHint() {
32
+ if (process.platform === 'darwin') return 'Install tmux with: brew install tmux';
33
+ if (process.platform === 'linux') {
34
+ if (fs.existsSync('/etc/debian_version')) return 'Install tmux with: sudo apt-get update && sudo apt-get install -y tmux';
35
+ if (fs.existsSync('/etc/alpine-release')) return 'Install tmux with: sudo apk add tmux';
36
+ if (fs.existsSync('/etc/arch-release')) return 'Install tmux with: sudo pacman -S --noconfirm tmux';
37
+ if (fs.existsSync('/etc/fedora-release')) return 'Install tmux with: sudo dnf install -y tmux';
38
+ return 'Install tmux with your system package manager.';
39
+ }
40
+ return 'Install tmux and make sure it is on PATH.';
41
+ }
42
+
43
+ function commandExists(name) {
44
+ return spawnSync('sh', ['-lc', `command -v ${shellQuoteArg(name)} >/dev/null 2>&1`], {
45
+ stdio: ['ignore', 'ignore', 'ignore']
46
+ }).status === 0;
47
+ }
48
+
49
+ function runInstallCommand(command) {
50
+ const result = spawnSync('sh', ['-lc', command], {
51
+ encoding: 'utf8',
52
+ stdio: ['ignore', 'pipe', 'pipe'],
53
+ timeout: 120000
54
+ });
55
+ return {
56
+ ok: result.status === 0,
57
+ output: [result.stdout, result.stderr].filter(Boolean).join('\n').trim()
58
+ };
59
+ }
60
+
61
+ export function tryInstallTmux() {
62
+ if (process.env.HCC_NO_AUTO_INSTALL_TMUX === '1') {
63
+ return { ok: false, output: 'automatic tmux installation disabled by HCC_NO_AUTO_INSTALL_TMUX=1' };
64
+ }
65
+ const sudo = typeof process.getuid === 'function' && process.getuid() === 0 ? '' : (commandExists('sudo') ? 'sudo ' : '');
66
+ if (process.platform === 'darwin' && commandExists('brew')) {
67
+ return runInstallCommand('brew install tmux');
68
+ }
69
+ if (process.platform === 'linux') {
70
+ if (commandExists('apt-get')) {
71
+ const update = runInstallCommand(`${sudo}apt-get update`);
72
+ if (!update.ok) return update;
73
+ return runInstallCommand(`${sudo}apt-get install -y tmux`);
74
+ }
75
+ if (commandExists('dnf')) return runInstallCommand(`${sudo}dnf install -y tmux`);
76
+ if (commandExists('yum')) return runInstallCommand(`${sudo}yum install -y tmux`);
77
+ if (commandExists('apk')) return runInstallCommand(`${sudo}apk add tmux`);
78
+ if (commandExists('pacman')) return runInstallCommand(`${sudo}pacman -S --noconfirm tmux`);
79
+ }
80
+ return { ok: false, output: 'no supported package manager found' };
81
+ }
82
+
83
+ export function ensureTmuxAvailable({ autoInstall = true } = {}) {
84
+ if (spawnSync('tmux', ['-V'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).status === 0) return;
85
+ let install = { ok: false, output: '' };
86
+ if (autoInstall) {
87
+ install = tryInstallTmux();
88
+ if (install.ok && spawnSync('tmux', ['-V'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).status === 0) return;
89
+ }
90
+ const detail = install.output ? `\n\nAutomatic install failed:\n${install.output}` : '';
91
+ throw new CliError('TMUX_REQUIRED', `tmux is required for browser-controllable local terminals. ${tmuxInstallHint()}${detail}`);
92
+ }
93
+
94
+ export function tmuxHasSession(sessionName) {
95
+ const result = spawnSync('tmux', ['has-session', '-t', sessionName], {
96
+ encoding: 'utf8',
97
+ stdio: ['ignore', 'pipe', 'pipe']
98
+ });
99
+ return result.status === 0;
100
+ }
101
+
102
+ export function tmuxSessionHasClients(sessionName) {
103
+ const result = spawnSync('tmux', ['list-clients', '-t', sessionName], {
104
+ encoding: 'utf8',
105
+ stdio: ['ignore', 'pipe', 'pipe']
106
+ });
107
+ if (result.status !== 0) return false;
108
+ return Boolean((result.stdout || '').trim());
109
+ }
110
+
111
+ export function tmuxKillSession(sessionName) {
112
+ const result = spawnSync('tmux', ['kill-session', '-t', sessionName], {
113
+ encoding: 'utf8',
114
+ stdio: ['ignore', 'pipe', 'pipe']
115
+ });
116
+ if (result.status !== 0 && !String(result.stderr || result.stdout || '').includes('can\'t find session')) {
117
+ const message = (result.stderr || result.stdout || '').trim() || `tmux kill-session -t ${sessionName} failed`;
118
+ throw new CliError('TMUX_ERROR', message);
119
+ }
120
+ }
121
+
122
+ export function tmuxSessionEnvironmentValue(sessionName, key) {
123
+ const result = spawnSync('tmux', ['show-environment', '-t', sessionName, key], {
124
+ encoding: 'utf8',
125
+ stdio: ['ignore', 'pipe', 'pipe']
126
+ });
127
+ if (result.status !== 0) return null;
128
+ const line = (result.stdout || '').trim();
129
+ if (!line || line.startsWith('-')) return null;
130
+ const prefix = `${key}=`;
131
+ return line.startsWith(prefix) ? line.slice(prefix.length) : null;
132
+ }
133
+
134
+ export function tmuxLaunchFingerprint(sessionName) {
135
+ return tmuxSessionEnvironmentValue(sessionName, LAUNCH_FINGERPRINT_ENV);
136
+ }
137
+
138
+ export function tmuxProviderState(sessionName) {
139
+ return tmuxSessionEnvironmentValue(sessionName, PROVIDER_STATE_ENV);
140
+ }
141
+
142
+ export function tmuxManagedSessionName(ctx, peerId) {
143
+ const name = `hcc-${shortHash(ctx.root)}-${sanitizePeerPart(peerId, 'peer')}`;
144
+ return name.slice(0, 80);
145
+ }
146
+
147
+ export function tmuxEnvironmentArgs(env) {
148
+ const args = [];
149
+ for (const [key, value] of Object.entries(env)) {
150
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
151
+ if (['TMUX', 'TMUX_PANE'].includes(key)) continue;
152
+ if (value === undefined || value === null) continue;
153
+ args.push('-e', `${key}=${String(value)}`);
154
+ }
155
+ return args;
156
+ }
157
+
158
+ export function tmuxPaneInfo(targetPane = null) {
159
+ const pane = targetPane || process.env.TMUX_PANE;
160
+ if (!pane) throw new CliError('BAD_ARGS', 'Missing --pane and current terminal is not inside tmux');
161
+ const format = '#{pane_id}\t#{pane_current_path}\t#{pane_pid}\t#{pane_current_command}\t#{pane_dead}';
162
+ const out = runTmux(['display-message', '-p', '-t', pane, format]).trimEnd();
163
+ const [paneId, currentPath, pid, currentCommand, paneDead] = out.split('\t');
164
+ if (!paneId) throw new CliError('TMUX_ERROR', `Cannot resolve tmux pane: ${pane}`);
165
+ return {
166
+ pane: paneId,
167
+ cwd: currentPath || '',
168
+ pid: Number.parseInt(pid || '0', 10) || null,
169
+ command: currentCommand || 'tmux',
170
+ dead: paneDead === '1'
171
+ };
172
+ }
173
+
174
+ export function tmuxCapturePane(pane) {
175
+ // No -J (join): keep one captured line per physical pane row so the web
176
+ // terminal's row count matches tmux exactly, which is required for accurate
177
+ // cursor placement. Strip the single trailing newline tmux appends so the
178
+ // line count is exactly scrollback+height (no phantom bottom row in xterm).
179
+ const out = runTmux(['capture-pane', '-p', '-e', '-S', '-2000', '-t', pane]);
180
+ return out.endsWith('\n') ? out.slice(0, -1) : out;
181
+ }
182
+
183
+ // Read the real cursor cell + screen geometry from tmux so the browser mirror
184
+ // can draw the input cursor at the right place. Returns null if unavailable.
185
+ export function tmuxCursorInfo(pane) {
186
+ try {
187
+ const out = runTmux(['display-message', '-p', '-t', pane,
188
+ '#{cursor_x},#{cursor_y},#{cursor_flag},#{history_size},#{pane_height}']);
189
+ const [x, y, flag, hist, height] = out.trim().split(',').map((n) => Number.parseInt(n, 10));
190
+ return {
191
+ x: Number.isFinite(x) ? x : 0,
192
+ y: Number.isFinite(y) ? y : 0,
193
+ visible: flag !== 0,
194
+ history: Number.isFinite(hist) ? hist : 0,
195
+ height: Number.isFinite(height) && height > 0 ? height : 1
196
+ };
197
+ } catch {
198
+ return null;
199
+ }
200
+ }
201
+
202
+ // Map tmux's cursor (relative to the visible pane) onto the web terminal's
203
+ // viewport row, accounting for captured scrollback and capture-pane's trailing
204
+ // blank-line stripping. Returns { row, col, visible } in viewport coordinates.
205
+ export function tmuxCursorPayload(captured, info) {
206
+ if (!info) return null;
207
+ const height = info.height;
208
+ const scrollback = Math.min(2000, info.history);
209
+ const lineCount = captured ? captured.split('\n').length : 0;
210
+ const viewportTop = Math.max(0, lineCount - height);
211
+ let row = scrollback + info.y - viewportTop;
212
+ if (row < 0) row = 0;
213
+ if (row > height - 1) row = height - 1;
214
+ return { row, col: info.x, visible: info.visible };
215
+ }
216
+
217
+ function tmuxSendKeys(pane, keys) {
218
+ if (!keys.length) return;
219
+ runTmux(['send-keys', '-t', pane, ...keys]);
220
+ }
221
+
222
+ function tmuxSendRawLiteral(pane, text) {
223
+ if (!text) return;
224
+ // A tmux argument consisting solely of ';' is swallowed as a command
225
+ // separator even after '--', so a typed lone semicolon would vanish. Paste
226
+ // such runs through a buffer (which is parsed as data, not command args).
227
+ if (/^;+$/.test(text)) {
228
+ tmuxPasteBuffer(pane, text, { raw: true });
229
+ return;
230
+ }
231
+ runTmux(['send-keys', '-t', pane, '-l', '--', text]);
232
+ }
233
+
234
+ function tmuxInCopyMode(pane) {
235
+ try {
236
+ const out = runTmux(['display-message', '-p', '-t', pane, '#{pane_in_mode}']);
237
+ return out.trim() === '1';
238
+ } catch { return false; }
239
+ }
240
+
241
+ function tmuxExitCopyMode(pane) {
242
+ try {
243
+ runTmux(['send-keys', '-t', pane, '-X', 'cancel']);
244
+ } catch {}
245
+ }
246
+
247
+ const BRACKETED_PASTE_START = '\x1b[200~';
248
+ const BRACKETED_PASTE_END = '\x1b[201~';
249
+
250
+ function tmuxPasteBuffer(pane, text, opts = {}) {
251
+ if (!text) return;
252
+
253
+ // Cancel copy-mode so input isn't swallowed
254
+ if (tmuxInCopyMode(pane)) {
255
+ tmuxExitCopyMode(pane);
256
+ }
257
+
258
+ // paste-buffer handles all special characters safely (including $ ` " ' \ | ; ~ # and multi-byte UTF-8)
259
+ const bufferName = `hcc-tmux-${pane.replace(/[%\\/]/g, '')}-${Date.now()}`;
260
+ try {
261
+ runTmux(['load-buffer', '-b', bufferName, '-'], { input: text });
262
+ const args = ['paste-buffer'];
263
+ if (opts.bracketed) args.push('-p');
264
+ if (opts.raw) args.push('-r');
265
+ args.push('-t', pane, '-b', bufferName);
266
+ runTmux(args);
267
+ } finally {
268
+ try { runTmux(['delete-buffer', '-b', bufferName], { silent: true }); } catch {}
269
+ }
270
+ }
271
+
272
+ function readTmuxEscapeSequence(text, start) {
273
+ let i = start + 1;
274
+ if (i >= text.length) return text.slice(start, i);
275
+ const marker = text[i];
276
+ if (marker === '[') {
277
+ i += 1;
278
+ while (i < text.length) {
279
+ const code = text.charCodeAt(i);
280
+ i += 1;
281
+ if (code >= 0x40 && code <= 0x7e) break;
282
+ }
283
+ return text.slice(start, i);
284
+ }
285
+ if (marker === 'O') {
286
+ return text.slice(start, Math.min(text.length, start + 3));
287
+ }
288
+ if (marker === ']') {
289
+ i += 1;
290
+ while (i < text.length) {
291
+ if (text.charCodeAt(i) === 0x07) return text.slice(start, i + 1);
292
+ if (text.charCodeAt(i) === 0x1b && text[i + 1] === '\\') return text.slice(start, i + 2);
293
+ i += 1;
294
+ }
295
+ return text.slice(start, i);
296
+ }
297
+ return text.slice(start, Math.min(text.length, start + 2));
298
+ }
299
+
300
+ function isTmuxRawControlChar(ch) {
301
+ const code = ch.charCodeAt(0);
302
+ return (code < 0x20 && ch !== '\r' && ch !== '\n' && ch !== '\b') ||
303
+ (code >= 0x80 && code <= 0x9f);
304
+ }
305
+
306
+ export function tmuxSendLiteral(pane, text) {
307
+ if (!text) return;
308
+ const chunks = [];
309
+ let current = '';
310
+ for (let i = 0; i < text.length;) {
311
+ const codePoint = text.codePointAt(i);
312
+ const ch = String.fromCodePoint(codePoint);
313
+ const width = ch.length;
314
+ if (ch === '\r' || ch === '\n') {
315
+ if (current) { chunks.push({ type: 'literal', text: current }); current = ''; }
316
+ chunks.push({ type: 'key', key: 'Enter' });
317
+ i += width;
318
+ } else if (ch === '\x7f' || ch === '\b') {
319
+ if (current) { chunks.push({ type: 'literal', text: current }); current = ''; }
320
+ chunks.push({ type: 'key', key: 'BSpace' });
321
+ i += width;
322
+ } else if (text.startsWith(BRACKETED_PASTE_START, i)) {
323
+ if (current) { chunks.push({ type: 'literal', text: current }); current = ''; }
324
+ const end = text.indexOf(BRACKETED_PASTE_END, i + BRACKETED_PASTE_START.length);
325
+ if (end >= 0) {
326
+ chunks.push({
327
+ type: 'paste',
328
+ text: text.slice(i + BRACKETED_PASTE_START.length, end)
329
+ });
330
+ i = end + BRACKETED_PASTE_END.length;
331
+ } else {
332
+ const sequence = readTmuxEscapeSequence(text, i);
333
+ chunks.push({ type: 'raw', text: sequence });
334
+ i += sequence.length;
335
+ }
336
+ } else if (ch === '\x1b') {
337
+ if (current) { chunks.push({ type: 'literal', text: current }); current = ''; }
338
+ const sequence = readTmuxEscapeSequence(text, i);
339
+ chunks.push({ type: 'raw', text: sequence });
340
+ i += sequence.length;
341
+ } else if (isTmuxRawControlChar(ch)) {
342
+ if (current) { chunks.push({ type: 'literal', text: current }); current = ''; }
343
+ chunks.push({ type: 'raw', text: ch });
344
+ i += width;
345
+ } else {
346
+ current += ch;
347
+ i += width;
348
+ }
349
+ }
350
+ if (current) chunks.push({ type: 'literal', text: current });
351
+
352
+ // Typed characters must arrive as real key presses, not clipboard pastes:
353
+ // send-keys -l is ~3x cheaper than the load-buffer/paste-buffer/delete-buffer
354
+ // cycle (one tmux spawn vs three) and the target program sees keystrokes
355
+ // rather than paste events — which bracketed-paste-aware TUIs (claude, codex)
356
+ // otherwise mishandle. paste-buffer is reserved for genuine bracketed pastes.
357
+ // Exit copy-mode once up front so send-keys isn't interpreted as copy commands.
358
+ if (tmuxInCopyMode(pane)) tmuxExitCopyMode(pane);
359
+
360
+ let pendingText = '';
361
+ for (const chunk of chunks) {
362
+ if (chunk.type === 'literal') {
363
+ pendingText += chunk.text;
364
+ } else {
365
+ if (pendingText) { tmuxSendRawLiteral(pane, pendingText); pendingText = ''; }
366
+ if (chunk.type === 'key') tmuxSendKeys(pane, [chunk.key]);
367
+ else if (chunk.type === 'paste') tmuxPasteBuffer(pane, chunk.text, { bracketed: true, raw: true });
368
+ else tmuxSendRawLiteral(pane, chunk.text);
369
+ }
370
+ }
371
+ if (pendingText) tmuxSendRawLiteral(pane, pendingText);
372
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ messageParticipants,
3
+ parseEventPayload,
4
+ payloadParticipants,
5
+ peerMatchesTimelineItem,
6
+ shouldHideTimelineEvent,
7
+ shouldHideTimelineMessage,
8
+ taskParticipants,
9
+ timelineDirection,
10
+ timelineFromRows,
11
+ uniqueList
12
+ } from './core/coordination/timeline.mjs';
package/lib/tmux.mjs ADDED
@@ -0,0 +1,18 @@
1
+ export {
2
+ ensureTmuxAvailable,
3
+ runTmux,
4
+ tmuxCapturePane,
5
+ tmuxCursorInfo,
6
+ tmuxCursorPayload,
7
+ tmuxEnvironmentArgs,
8
+ tmuxHasSession,
9
+ tmuxKillSession,
10
+ tmuxLaunchFingerprint,
11
+ tmuxManagedSessionName,
12
+ tmuxPaneInfo,
13
+ tmuxProviderState,
14
+ tmuxSendLiteral,
15
+ tmuxSessionEnvironmentValue,
16
+ tmuxSessionHasClients,
17
+ tryInstallTmux
18
+ } from './terminal/tmux.mjs';