@logicseek/hello-cc 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +130 -0
- package/README.md +21 -3
- package/README.zh-CN.md +21 -3
- package/assets/screenshots/web-console.png +0 -0
- package/bin/hcc.mjs +1943 -3519
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +3 -1
- package/docs/architecture-split-review.md +217 -0
- package/docs/architecture.md +385 -0
- package/docs/architecture.zh-CN.md +364 -0
- package/docs/layout-split-stack-audit.md +298 -0
- package/lib/automation.mjs +8 -0
- package/lib/cli-args.mjs +1 -1
- package/lib/cli-runtime.mjs +45 -0
- package/lib/coordination-state.mjs +218 -0
- package/lib/core/coordination/automation.mjs +222 -0
- package/lib/core/coordination/locks.mjs +46 -0
- package/lib/core/coordination/messages.mjs +116 -0
- package/lib/core/coordination/tasks.mjs +240 -0
- package/lib/core/coordination/teams.mjs +73 -0
- package/lib/core/coordination/timeline.mjs +196 -0
- package/lib/core/peers/bindings.mjs +83 -0
- package/lib/core/peers/format.mjs +13 -0
- package/lib/core/peers/identity.mjs +61 -0
- package/lib/core/peers/liveness.mjs +90 -0
- package/lib/core/peers/reconcile.mjs +238 -0
- package/lib/core/peers/session.mjs +14 -0
- package/lib/core/sessions/launch.mjs +89 -0
- package/lib/db/schema.mjs +324 -0
- package/lib/db/stores/peers.mjs +231 -0
- package/lib/db-schema.mjs +7 -324
- package/lib/errors.mjs +1 -7
- package/lib/handoff.mjs +26 -0
- package/lib/help.mjs +3 -0
- package/lib/integrations/hooks.mjs +171 -0
- package/lib/integrations/peers/identity.mjs +208 -0
- package/lib/integrations/peers/processes.mjs +151 -0
- package/lib/integrations/providers.mjs +282 -0
- package/lib/integrations/shims/script.mjs +384 -0
- package/lib/integrations/shims.mjs +106 -0
- package/lib/json-file.mjs +1 -14
- package/lib/locks.mjs +10 -0
- package/lib/messages.mjs +3 -0
- package/lib/package-meta.mjs +6 -24
- package/lib/peer-bindings.mjs +14 -0
- package/lib/peer-format.mjs +4 -0
- package/lib/peer-identity.mjs +15 -0
- package/lib/project-context.mjs +6 -0
- package/lib/project-registry.mjs +7 -0
- package/lib/provider-commands.mjs +13 -0
- package/lib/release/package-meta.mjs +31 -0
- package/lib/release/release-notes.mjs +40 -0
- package/lib/release-notes.mjs +6 -40
- package/lib/runtime/client.mjs +42 -0
- package/lib/runtime/paths.mjs +45 -0
- package/lib/runtime/project-context.mjs +32 -0
- package/lib/runtime/projects.mjs +76 -0
- package/lib/runtime/state.mjs +128 -0
- package/lib/runtime-client.mjs +3 -0
- package/lib/runtime-paths.mjs +11 -45
- package/lib/runtime-state.mjs +11 -0
- package/lib/session-launch.mjs +17 -0
- package/lib/setup.mjs +17 -293
- package/lib/shared/errors.mjs +7 -0
- package/lib/shared/json-file.mjs +14 -0
- package/lib/shim-script.mjs +1 -384
- package/lib/state-render.mjs +5 -0
- package/lib/task-cli.mjs +45 -0
- package/lib/task-liveness.mjs +8 -0
- package/lib/task-store.mjs +3 -0
- package/lib/team-planning.mjs +7 -0
- package/lib/terminal/tmux.mjs +372 -0
- package/lib/timeline.mjs +12 -0
- package/lib/tmux.mjs +18 -0
- package/lib/ui/help.mjs +419 -0
- package/lib/ui/state-render.mjs +61 -0
- package/lib/web/http.mjs +55 -0
- package/lib/web/peer-actions.mjs +306 -0
- package/lib/web/runtime.mjs +154 -0
- package/lib/web/ui-template.mjs +2057 -0
- package/lib/web-http.mjs +7 -55
- package/lib/web-peer-actions.mjs +3 -0
- package/lib/web-runtime.mjs +17 -108
- package/lib/web-ui-template.mjs +3 -1909
- package/package.json +1 -1
- package/scripts/github-release.mjs +2 -2
- package/scripts/regression.mjs +2901 -79
- package/scripts/release-notes.mjs +2 -2
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
2
|
+
|
|
3
|
+
function nowSec() {
|
|
4
|
+
return Math.floor(Date.now() / 1000);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function taskRelatedLocks(task, locks) {
|
|
8
|
+
const taskId = Number(task?.id || 0);
|
|
9
|
+
const owner = task?.owner || '';
|
|
10
|
+
return (locks || []).filter((lock) => {
|
|
11
|
+
if (taskId && Number(lock.task_id || 0) === taskId) return true;
|
|
12
|
+
return Boolean(owner && lock.owner === owner);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function taskOwnerLiveness(task, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
|
|
17
|
+
const owner = task?.owner || null;
|
|
18
|
+
const relatedLocks = taskRelatedLocks(task, locks);
|
|
19
|
+
if (!owner) {
|
|
20
|
+
return {
|
|
21
|
+
owner_known: false,
|
|
22
|
+
owner_active: null,
|
|
23
|
+
owner_stale: false,
|
|
24
|
+
owner_age_sec: null,
|
|
25
|
+
related_lock_count: relatedLocks.length,
|
|
26
|
+
takeover_ready: false
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const ownerRow = (peers || []).find((row) => row.id === owner) || null;
|
|
30
|
+
const ownerAge = ownerRow
|
|
31
|
+
? Number(ownerRow.age_sec ?? (t - Number(ownerRow.last_seen_at || 0)))
|
|
32
|
+
: null;
|
|
33
|
+
const ownerActive = Boolean(ownerRow && Number.isFinite(ownerAge) && ownerAge <= activePeerTtl);
|
|
34
|
+
const ownerStale = !ownerActive;
|
|
35
|
+
const takeoverStatus = ['claimed', 'running', 'review', 'blocked'].includes(task.status);
|
|
36
|
+
return {
|
|
37
|
+
owner_known: Boolean(ownerRow),
|
|
38
|
+
owner_active: ownerActive,
|
|
39
|
+
owner_stale: ownerStale,
|
|
40
|
+
owner_age_sec: Number.isFinite(ownerAge) ? ownerAge : null,
|
|
41
|
+
related_lock_count: relatedLocks.length,
|
|
42
|
+
takeover_ready: Boolean(takeoverStatus && ownerStale && relatedLocks.length === 0)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function annotateTasksWithLiveness(tasks, peers, locks, t = nowSec(), activePeerTtl = DEFAULT_ACTIVE_PEER_TTL) {
|
|
47
|
+
return (tasks || []).map((task) => ({
|
|
48
|
+
...task,
|
|
49
|
+
...taskOwnerLiveness(task, peers, locks, t, activePeerTtl)
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function taskOwnerStateText(task) {
|
|
54
|
+
if (!task?.owner) return '';
|
|
55
|
+
if (task.owner_stale) {
|
|
56
|
+
if (task.takeover_ready) return 'stale/no-lock';
|
|
57
|
+
const locks = Number(task.related_lock_count || 0);
|
|
58
|
+
return locks ? `stale/locks=${locks}` : 'stale';
|
|
59
|
+
}
|
|
60
|
+
if (task.owner_active) return 'active';
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function summarizeTask(task) {
|
|
65
|
+
if (!task) return null;
|
|
66
|
+
return {
|
|
67
|
+
id: task.id,
|
|
68
|
+
title: task.title,
|
|
69
|
+
status: task.status,
|
|
70
|
+
owner: task.owner || null,
|
|
71
|
+
assignee: task.assignee || null,
|
|
72
|
+
parent_id: task.parent_id || null,
|
|
73
|
+
team_role: task.team_role || null,
|
|
74
|
+
priority: task.priority,
|
|
75
|
+
owner_active: task.owner_active ?? null,
|
|
76
|
+
owner_stale: Boolean(task.owner_stale),
|
|
77
|
+
owner_age_sec: task.owner_age_sec ?? null,
|
|
78
|
+
related_lock_count: Number(task.related_lock_count || 0),
|
|
79
|
+
takeover_ready: Boolean(task.takeover_ready)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatOpenTaskLine(task) {
|
|
84
|
+
const parts = [`#${task.id}`, task.status];
|
|
85
|
+
if (task.owner) parts.push(`owner=${task.owner}`);
|
|
86
|
+
if (task.assignee) parts.push(`assignee=${task.assignee}`);
|
|
87
|
+
const ownerState = taskOwnerStateText(task);
|
|
88
|
+
if (ownerState) parts.push(`owner_state=${ownerState}`);
|
|
89
|
+
return `${parts.join(' ')}: ${task.title}`;
|
|
90
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { providerSessionParts } from './session.mjs';
|
|
3
|
+
import {
|
|
4
|
+
bindingHasProviderSession,
|
|
5
|
+
bindingProviderSessionValue
|
|
6
|
+
} from './bindings.mjs';
|
|
7
|
+
|
|
8
|
+
function defaultNow() {
|
|
9
|
+
return Math.floor(Date.now() / 1000);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function noopEvent() {}
|
|
13
|
+
|
|
14
|
+
function normalizedRoot(root) {
|
|
15
|
+
return root ? path.resolve(root) : '';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runtimePaneMatches(row, pane) {
|
|
19
|
+
return Boolean(row?.runtime_target && pane?.pane && row.runtime_target === pane.pane);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function runtimePidMatches(row, pane) {
|
|
23
|
+
return Boolean(row?.pid && pane?.pid && Number(row.pid) === Number(pane.pid));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function runtimeCwdMatches(root, row, pane) {
|
|
27
|
+
const paneCwd = normalizedRoot(pane?.cwd);
|
|
28
|
+
if (!paneCwd) return false;
|
|
29
|
+
const worktree = normalizedRoot(row?.worktree);
|
|
30
|
+
const projectRoot = normalizedRoot(root);
|
|
31
|
+
return Boolean((worktree && paneCwd === worktree) || (projectRoot && paneCwd === projectRoot));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function commandEvidence(row) {
|
|
35
|
+
const value = row?.resume_arg || null;
|
|
36
|
+
if (!value || value === '--last' || String(value).startsWith('%')) return null;
|
|
37
|
+
if (!['resume', 'session', 'detected', 'named'].includes(row?.resume_mode)) return null;
|
|
38
|
+
return {
|
|
39
|
+
provider: row.provider || row.kind || null,
|
|
40
|
+
value,
|
|
41
|
+
source: `binding.${row.resume_mode}`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function processEvidence(row, inspectProcess) {
|
|
46
|
+
if (typeof inspectProcess !== 'function' || !row?.pid) return null;
|
|
47
|
+
const info = inspectProcess(Number(row.pid), row.provider || row.kind || null);
|
|
48
|
+
if (!info?.provider_session) return null;
|
|
49
|
+
return {
|
|
50
|
+
provider: info.kind || row.provider || row.kind || null,
|
|
51
|
+
value: info.provider_session,
|
|
52
|
+
source: info.source || 'process'
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function tmuxEvidence(row, pane, opts) {
|
|
57
|
+
const provider = row.provider || row.kind || null;
|
|
58
|
+
const byEvent = eventEvidence(row, opts);
|
|
59
|
+
if (byEvent) return byEvent;
|
|
60
|
+
const byProcess = processEvidence(row, opts.inspectProcess);
|
|
61
|
+
if (byProcess) return byProcess;
|
|
62
|
+
const byCommand = commandEvidence(row);
|
|
63
|
+
if (!byCommand) return null;
|
|
64
|
+
if (!runtimePaneMatches(row, pane) && !runtimePidMatches(row, pane)) return null;
|
|
65
|
+
return {
|
|
66
|
+
...byCommand,
|
|
67
|
+
provider
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function eventEvidence(row, opts) {
|
|
72
|
+
if (typeof opts.latestProviderSessionForPeer !== 'function') return null;
|
|
73
|
+
const value = opts.latestProviderSessionForPeer(row.peer, row.provider || row.kind || null);
|
|
74
|
+
if (!value) return null;
|
|
75
|
+
return {
|
|
76
|
+
provider: row.provider || row.kind || null,
|
|
77
|
+
value,
|
|
78
|
+
source: 'events.hook'
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function existingProviderSessionConflict(db, row, session, provider = row?.provider) {
|
|
83
|
+
if (!session?.provider_session_id && !session?.provider_session_name) return null;
|
|
84
|
+
if (!provider) return null;
|
|
85
|
+
if (session.provider_session_id) {
|
|
86
|
+
return db.prepare(`
|
|
87
|
+
SELECT *
|
|
88
|
+
FROM peer_bindings
|
|
89
|
+
WHERE provider = ?
|
|
90
|
+
AND peer <> ?
|
|
91
|
+
AND provider_session_id = ?
|
|
92
|
+
LIMIT 1
|
|
93
|
+
`).get(provider, row.peer, session.provider_session_id) || null;
|
|
94
|
+
}
|
|
95
|
+
return db.prepare(`
|
|
96
|
+
SELECT *
|
|
97
|
+
FROM peer_bindings
|
|
98
|
+
WHERE provider = ?
|
|
99
|
+
AND peer <> ?
|
|
100
|
+
AND provider_session_name = ?
|
|
101
|
+
LIMIT 1
|
|
102
|
+
`).get(provider, row.peer, session.provider_session_name) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function updatePeerProviderSession(db, row, candidate, opts) {
|
|
106
|
+
const t = opts.now();
|
|
107
|
+
const provider = candidate.provider || row.provider;
|
|
108
|
+
const session = providerSessionParts(candidate.value);
|
|
109
|
+
const conflict = existingProviderSessionConflict(db, row, session, provider);
|
|
110
|
+
if (conflict) {
|
|
111
|
+
return {
|
|
112
|
+
peer: row.peer,
|
|
113
|
+
provider,
|
|
114
|
+
action: 'skipped',
|
|
115
|
+
reason: 'provider_session_conflict',
|
|
116
|
+
conflict_peer: conflict.peer,
|
|
117
|
+
provider_session: bindingProviderSessionValue(session)
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
db.prepare(`
|
|
123
|
+
UPDATE peer_bindings
|
|
124
|
+
SET provider = ?,
|
|
125
|
+
provider_session_id = ?,
|
|
126
|
+
provider_session_name = ?,
|
|
127
|
+
resume_mode = CASE
|
|
128
|
+
WHEN resume_mode IN ('unknown', 'attached', 'auto', 'command') THEN ?
|
|
129
|
+
ELSE resume_mode
|
|
130
|
+
END,
|
|
131
|
+
resume_arg = COALESCE(NULLIF(resume_arg, runtime_target), ?),
|
|
132
|
+
updated_at = ?
|
|
133
|
+
WHERE peer = ?
|
|
134
|
+
`).run(
|
|
135
|
+
provider,
|
|
136
|
+
session.provider_session_id || null,
|
|
137
|
+
session.provider_session_name || null,
|
|
138
|
+
candidate.source === 'binding.session' ? 'session' : 'detected',
|
|
139
|
+
candidate.value,
|
|
140
|
+
t,
|
|
141
|
+
row.peer
|
|
142
|
+
);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const text = `${err?.code || ''} ${err?.message || ''} ${err?.errstr || ''}`;
|
|
145
|
+
if (!/UNIQUE constraint failed/i.test(text)) throw err;
|
|
146
|
+
const conflict = existingProviderSessionConflict(db, row, session, provider);
|
|
147
|
+
return {
|
|
148
|
+
peer: row.peer,
|
|
149
|
+
provider,
|
|
150
|
+
action: 'skipped',
|
|
151
|
+
reason: 'provider_session_conflict',
|
|
152
|
+
conflict_peer: conflict?.peer || null,
|
|
153
|
+
provider_session: bindingProviderSessionValue(session)
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
opts.addEvent(db, 'provider.session.backfilled', row.peer, null, {
|
|
157
|
+
provider,
|
|
158
|
+
provider_session: candidate.value,
|
|
159
|
+
source: candidate.source,
|
|
160
|
+
runtime_target: row.runtime_target || null
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
peer: row.peer,
|
|
164
|
+
provider,
|
|
165
|
+
action: 'backfilled',
|
|
166
|
+
source: candidate.source,
|
|
167
|
+
provider_session: candidate.value,
|
|
168
|
+
runtime_target: row.runtime_target || null
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function reconcileRunningPeerBindings(db, projectCtx = {}, options = {}) {
|
|
173
|
+
const opts = {
|
|
174
|
+
now: typeof options.now === 'function' ? options.now : defaultNow,
|
|
175
|
+
addEvent: typeof options.addEvent === 'function' ? options.addEvent : noopEvent,
|
|
176
|
+
inspectProcess: options.inspectProcess || null,
|
|
177
|
+
latestProviderSessionForPeer: options.latestProviderSessionForPeer || null,
|
|
178
|
+
panes: Array.isArray(options.panes) ? options.panes : []
|
|
179
|
+
};
|
|
180
|
+
const paneByTarget = new Map(opts.panes.filter((pane) => pane?.pane).map((pane) => [pane.pane, pane]));
|
|
181
|
+
const paneByPid = new Map();
|
|
182
|
+
for (const pane of opts.panes) {
|
|
183
|
+
if (pane?.pid && !paneByPid.has(Number(pane.pid))) paneByPid.set(Number(pane.pid), pane);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rows = db.prepare(`
|
|
187
|
+
SELECT p.id AS peer, p.kind, p.status, p.worktree, p.pid, p.last_seen_at,
|
|
188
|
+
b.provider, b.provider_session_id, b.provider_session_name,
|
|
189
|
+
b.resume_mode, b.resume_arg, b.command, b.transport,
|
|
190
|
+
b.runtime_session_id, b.runtime_target, b.updated_at
|
|
191
|
+
FROM peer_bindings b
|
|
192
|
+
JOIN peers p ON p.id = b.peer
|
|
193
|
+
WHERE b.transport = 'tmux'
|
|
194
|
+
AND b.runtime_target IS NOT NULL
|
|
195
|
+
AND p.status IN ('running', 'working', 'busy')
|
|
196
|
+
ORDER BY p.last_seen_at DESC, b.updated_at DESC
|
|
197
|
+
`).all();
|
|
198
|
+
|
|
199
|
+
const results = [];
|
|
200
|
+
for (const row of rows) {
|
|
201
|
+
if (bindingHasProviderSession(row)) {
|
|
202
|
+
results.push({
|
|
203
|
+
peer: row.peer,
|
|
204
|
+
provider: row.provider,
|
|
205
|
+
action: 'skipped',
|
|
206
|
+
reason: 'provider_session_known',
|
|
207
|
+
provider_session: bindingProviderSessionValue(row)
|
|
208
|
+
});
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const pane = paneByTarget.get(row.runtime_target) || paneByPid.get(Number(row.pid));
|
|
213
|
+
if (!pane) {
|
|
214
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'no_live_tmux_pane' });
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (!runtimePaneMatches(row, pane)) {
|
|
218
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'runtime_target_mismatch' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!runtimePidMatches(row, pane) && !runtimeCwdMatches(projectCtx.root, row, pane)) {
|
|
222
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'weak_runtime_evidence' });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const candidate = tmuxEvidence(row, pane, opts);
|
|
227
|
+
if (!candidate?.value) {
|
|
228
|
+
results.push({ peer: row.peer, provider: row.provider, action: 'skipped', reason: 'no_strong_provider_evidence' });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
results.push(updatePeerProviderSession(db, row, candidate, opts));
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
checked: rows.length,
|
|
235
|
+
backfilled: results.filter((result) => result.action === 'backfilled').length,
|
|
236
|
+
results
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { sanitizePeerPart, shortHash } from './format.mjs';
|
|
2
|
+
|
|
3
|
+
export function providerSessionPeerId(kind, providerId) {
|
|
4
|
+
return `${kind}-${sanitizePeerPart(String(providerId || '').slice(0, 8), shortHash(providerId))}`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function providerSessionParts(value) {
|
|
8
|
+
if (!value) return { provider_session_id: null, provider_session_name: null };
|
|
9
|
+
const text = String(value);
|
|
10
|
+
const uuidLike = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text);
|
|
11
|
+
return uuidLike
|
|
12
|
+
? { provider_session_id: text, provider_session_name: null }
|
|
13
|
+
: { provider_session_id: null, provider_session_name: text };
|
|
14
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { shortHash } from '../peers/format.mjs';
|
|
5
|
+
|
|
6
|
+
export const WEB_CHILD_ENV = 'HCC_WEB_CHILD';
|
|
7
|
+
export const LAUNCH_FINGERPRINT_ENV = 'HCC_LAUNCH_FINGERPRINT';
|
|
8
|
+
export const PROVIDER_STATE_ENV = 'HCC_PROVIDER_STATE';
|
|
9
|
+
|
|
10
|
+
const LAUNCH_ENV_IGNORED_KEYS = new Set([
|
|
11
|
+
'_',
|
|
12
|
+
'COLUMNS',
|
|
13
|
+
'HCC_DB',
|
|
14
|
+
'HCC_NO_AUTO_INSTALL_TMUX',
|
|
15
|
+
'HCC_PEER',
|
|
16
|
+
'HCC_ROOT',
|
|
17
|
+
PROVIDER_STATE_ENV,
|
|
18
|
+
'HCC_SHIM_ENSURED',
|
|
19
|
+
'HCC_SHIM_NO_ATTACH',
|
|
20
|
+
'HCC_WEB_TOKEN',
|
|
21
|
+
LAUNCH_FINGERPRINT_ENV,
|
|
22
|
+
'LINES',
|
|
23
|
+
'OLDPWD',
|
|
24
|
+
'PROMPT_COMMAND',
|
|
25
|
+
'PS1',
|
|
26
|
+
'PS2',
|
|
27
|
+
'PS4',
|
|
28
|
+
'PWD',
|
|
29
|
+
'SHLVL',
|
|
30
|
+
'TERM',
|
|
31
|
+
'TERMCAP',
|
|
32
|
+
'TMUX',
|
|
33
|
+
'TMUX_PANE',
|
|
34
|
+
'WINDOWID'
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function childSessionEnv(extra = {}, baseEnv = process.env) {
|
|
38
|
+
const env = { ...(baseEnv || {}), ...extra };
|
|
39
|
+
delete env[WEB_CHILD_ENV];
|
|
40
|
+
return env;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function launchEnvironmentFingerprint(env) {
|
|
44
|
+
const entries = Object.entries(env || {})
|
|
45
|
+
.filter(([key, value]) =>
|
|
46
|
+
value !== undefined &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
!LAUNCH_ENV_IGNORED_KEYS.has(key) &&
|
|
49
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)
|
|
50
|
+
)
|
|
51
|
+
.map(([key, value]) => [key, String(value)])
|
|
52
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
53
|
+
return shortHash(JSON.stringify(entries));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function launchFingerprint({ command, cwd, env }) {
|
|
57
|
+
return shortHash(JSON.stringify({
|
|
58
|
+
command: command || '',
|
|
59
|
+
cwd: cwd || '',
|
|
60
|
+
env: launchEnvironmentFingerprint(env || {})
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isLikelyShellCommand(command) {
|
|
65
|
+
const base = path.basename(String(command || '')).replace(/^-/, '');
|
|
66
|
+
return new Set(['bash', 'dash', 'fish', 'ksh', 'mksh', 'sh', 'zsh']).has(base);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isProviderFallbackWrapper(command) {
|
|
70
|
+
const text = String(command || '');
|
|
71
|
+
return text.includes(PROVIDER_STATE_ENV) || /\bexec\s+(?:bash|dash|fish|ksh|mksh|sh|zsh)\b/.test(text);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isRelaunchableProviderSession(kind, command, binding = {}) {
|
|
75
|
+
const provider = binding.provider || kind;
|
|
76
|
+
return ['claude', 'codex'].includes(provider) && isProviderFallbackWrapper(command);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isolatedEnvCommandArgs(env) {
|
|
80
|
+
const envBin = fs.existsSync('/usr/bin/env') ? '/usr/bin/env' : 'env';
|
|
81
|
+
const args = [envBin, '-i'];
|
|
82
|
+
for (const [key, value] of Object.entries(env || {})) {
|
|
83
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
84
|
+
if (['TMUX', 'TMUX_PANE'].includes(key)) continue;
|
|
85
|
+
if (value === undefined || value === null) continue;
|
|
86
|
+
args.push(`${key}=${String(value)}`);
|
|
87
|
+
}
|
|
88
|
+
return args;
|
|
89
|
+
}
|