@logicseek/hello-cc 0.1.9 → 1.0.1

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 (99) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -3
  3. package/README.zh-CN.md +48 -3
  4. package/bin/hcc.mjs +3 -5656
  5. package/docs/README.md +8 -0
  6. package/docs/README.zh-CN.md +6 -0
  7. package/docs/architecture.md +29 -18
  8. package/docs/architecture.zh-CN.md +20 -10
  9. package/docs/commands.md +13 -4
  10. package/docs/commands.zh-CN.md +13 -4
  11. package/docs/guide.md +97 -3
  12. package/docs/guide.zh-CN.md +89 -2
  13. package/lib/cli/app.mjs +644 -0
  14. package/lib/cli/commands/coordination.mjs +249 -0
  15. package/lib/cli/commands/doctor.mjs +81 -0
  16. package/lib/cli/commands/gc.mjs +1002 -0
  17. package/lib/cli/commands/hook.mjs +205 -0
  18. package/lib/cli/commands/install.mjs +294 -0
  19. package/lib/cli/commands/lifecycle.mjs +101 -0
  20. package/lib/cli/commands/lock.mjs +197 -0
  21. package/lib/cli/commands/misc.mjs +76 -0
  22. package/lib/cli/commands/msg.mjs +152 -0
  23. package/lib/cli/commands/peer.mjs +249 -0
  24. package/lib/cli/commands/query.mjs +96 -0
  25. package/lib/cli/commands/run.mjs +463 -0
  26. package/lib/cli/commands/task.mjs +405 -0
  27. package/lib/cli/commands/team.mjs +131 -0
  28. package/lib/cli/commands/tmux.mjs +838 -0
  29. package/lib/cli/commands/up.mjs +135 -0
  30. package/lib/cli-args.mjs +16 -2
  31. package/lib/coordination-state.mjs +112 -51
  32. package/lib/core/coordination/automation.mjs +5 -8
  33. package/lib/core/coordination/clock-safety.mjs +323 -0
  34. package/lib/core/coordination/event-retention.mjs +17 -0
  35. package/lib/core/coordination/gc-plan.mjs +614 -0
  36. package/lib/core/coordination/lease-renewal.mjs +102 -0
  37. package/lib/core/coordination/lock-evidence.mjs +77 -0
  38. package/lib/core/coordination/optimistic-evidence.mjs +26 -0
  39. package/lib/core/coordination/tasks.mjs +156 -36
  40. package/lib/core/peers/evidence-runtime.mjs +312 -0
  41. package/lib/core/peers/evidence.mjs +103 -0
  42. package/lib/core/peers/hook-owner.mjs +32 -0
  43. package/lib/core/peers/liveness.mjs +44 -4
  44. package/lib/core/peers/peer-helpers.mjs +121 -0
  45. package/lib/core/peers/session.mjs +8 -2
  46. package/lib/core/peers/tmux-safety.mjs +290 -0
  47. package/lib/core/sessions/launch.mjs +13 -1
  48. package/lib/db/connection.mjs +104 -0
  49. package/lib/db/events.mjs +34 -0
  50. package/lib/db/migration-backup.mjs +225 -0
  51. package/lib/db/schema.mjs +102 -5
  52. package/lib/db/stores/peers.mjs +34 -0
  53. package/lib/integrations/peers/identity.mjs +62 -16
  54. package/lib/integrations/shims/script.mjs +111 -9
  55. package/lib/process/identity.mjs +222 -0
  56. package/lib/process/pty-lifecycle.mjs +149 -0
  57. package/lib/runtime/buffer-directory-lease.mjs +48 -0
  58. package/lib/runtime/buffer-evidence.mjs +336 -0
  59. package/lib/runtime/buffer-gc-protocol.mjs +167 -0
  60. package/lib/runtime/buffer-gc.mjs +270 -0
  61. package/lib/runtime/client.mjs +63 -7
  62. package/lib/runtime/fatal-shutdown.mjs +54 -0
  63. package/lib/runtime/project-path.mjs +139 -0
  64. package/lib/runtime/projects.mjs +320 -40
  65. package/lib/runtime/state.mjs +227 -27
  66. package/lib/shared/cleanup-error.mjs +54 -0
  67. package/lib/shared/clock-grace.mjs +106 -0
  68. package/lib/shared/errors.mjs +9 -0
  69. package/lib/shared/file-lock.mjs +377 -0
  70. package/lib/shared/json-file.mjs +6 -2
  71. package/lib/shared/redact.mjs +60 -0
  72. package/lib/shared/socket-lock-worker.mjs +250 -0
  73. package/lib/terminal/tmux.mjs +131 -27
  74. package/lib/tmux.mjs +4 -0
  75. package/lib/ui/help.mjs +53 -15
  76. package/lib/web/api-version.mjs +42 -0
  77. package/lib/web/auto-attach.mjs +185 -0
  78. package/lib/web/buffer-gc-runtime.mjs +175 -0
  79. package/lib/web/cookie-auth.mjs +119 -0
  80. package/lib/web/external-sessions.mjs +254 -0
  81. package/lib/web/http-routes.mjs +542 -0
  82. package/lib/web/http.mjs +135 -6
  83. package/lib/web/liveness-reaper.mjs +145 -0
  84. package/lib/web/peer-actions.mjs +132 -23
  85. package/lib/web/project-contexts.mjs +183 -0
  86. package/lib/web/pty-sessions.mjs +203 -0
  87. package/lib/web/runtime-main.mjs +717 -0
  88. package/lib/web/runtime.mjs +91 -36
  89. package/lib/web/session-serialize.mjs +0 -0
  90. package/lib/web/startup.mjs +335 -0
  91. package/lib/web/tls.mjs +365 -0
  92. package/lib/web/tmux-sessions.mjs +1361 -0
  93. package/lib/web/tmux-stream.mjs +166 -0
  94. package/lib/web/ui-template.mjs +152 -20
  95. package/package.json +6 -3
  96. package/scripts/check-free-identifiers.mjs +290 -0
  97. package/scripts/regression.mjs +5404 -273
  98. package/docs/architecture-split-review.md +0 -217
  99. package/docs/layout-split-stack-audit.md +0 -298
@@ -0,0 +1,183 @@
1
+ // Web project context management, extracted from lib/web/runtime-main.mjs.
2
+ // Owns the projectContexts map: normalization, registry-backed discovery,
3
+ // fail-closed path re-resolution, per-project DB connections, and
4
+ // session/peer lookup helpers used by the HTTP and WS layers.
5
+
6
+ import path from 'node:path';
7
+ import { randomBytes } from 'node:crypto';
8
+ import WebSocket from 'ws';
9
+ import { CliError } from '../shared/errors.mjs';
10
+ import { contextForProject } from '../runtime/paths.mjs';
11
+ import { resolveProjectDatabase } from '../runtime/project-path.mjs';
12
+ import {
13
+ projectRecord,
14
+ readProjectRegistry,
15
+ registerProject,
16
+ registerProjectActivity
17
+ } from '../runtime/projects.mjs';
18
+ import { nextSessionId } from '../web/runtime.mjs';
19
+ import { tokenMatches } from '../web/http.mjs';
20
+
21
+ export function createProjectContexts(deps) {
22
+ const {
23
+ ctx, sessions,
24
+ sessionKey, sessionsForProject, resolveSessionPeerId,
25
+ connect, now, addEvent, tx, touchPeer, upsertPeer, detectBranch,
26
+ ACTIVE_PEER_TTL, CLI_NAME, DEFAULT_LOCK_TTL,
27
+ queryInbox, queryOpenTasks, queryTimelineMessages,
28
+ observePeerEvidence, peerEvidenceFromDb,
29
+ observeClockSafetyInTransaction,
30
+ assertTaskOwnerForMutation, claimNextTasksForPeer, takeOverTaskForPeer,
31
+ positiveIntOpt, sameResolvedPath
32
+ } = deps;
33
+
34
+ const projectContexts = new Map();
35
+
36
+ function newSessionActionToken() {
37
+ return randomBytes(32).toString('base64url');
38
+ }
39
+
40
+ function rememberProject(projectCtx, { activity = false, register = false, nonblocking = false } = {}) {
41
+ const normalized = contextForProject(projectCtx.root, projectCtx.dbPath, { cwd: projectCtx.cwd, json: ctx.json });
42
+ const isNew = !projectContexts.has(normalized.root);
43
+ // Activity refresh is already nonblocking and throttled before it tries the
44
+ // registry lock. Calling it on each request keeps last_seen_at current
45
+ // without putting the synchronous lock back on the HTTP hot path.
46
+ if (activity) registerProjectActivity(normalized);
47
+ else if (isNew || register) {
48
+ try {
49
+ registerProject(normalized, { nonblocking });
50
+ } catch (error) {
51
+ if (nonblocking && ['ERR_FILE_LOCK_BUSY', 'ERR_FILE_LOCK_TIMEOUT'].includes(error?.code)) {
52
+ throw new CliError('REGISTRY_BUSY', 'Project registry is busy; retry the request');
53
+ }
54
+ throw error;
55
+ }
56
+ }
57
+ projectContexts.set(normalized.root, normalized);
58
+ return normalized;
59
+ }
60
+
61
+ function knownProjects() {
62
+ const rows = readProjectRegistry();
63
+ if (!rows.some((p) => sameResolvedPath(p.root, ctx.root))) rows.unshift(projectRecord(ctx));
64
+ for (const project of rows) {
65
+ if (!projectContexts.has(project.root)) {
66
+ projectContexts.set(project.root, contextForProject(project.root, project.db, { json: ctx.json }));
67
+ }
68
+ }
69
+ return rows;
70
+ }
71
+
72
+ function resolveWebProjectContext(root, db) {
73
+ const first = resolveProjectDatabase({ root, db, createStateDir: true });
74
+ // Re-read every filesystem identity immediately before the returned
75
+ // context can reach connect(). A path rebound after the first pass fails
76
+ // closed instead of carrying a stale lexical decision into SQLite.
77
+ const final = resolveProjectDatabase({
78
+ root: first.root,
79
+ db: first.db,
80
+ createStateDir: true
81
+ });
82
+ return contextForProject(final.root, final.db, { cwd: final.root, json: ctx.json });
83
+ }
84
+
85
+ function connectWebProject(projectCtx, options = {}) {
86
+ const final = resolveProjectDatabase({
87
+ root: projectCtx.root,
88
+ db: projectCtx.dbPath,
89
+ createStateDir: options.create !== false
90
+ });
91
+ return connect(contextForProject(final.root, final.db, {
92
+ cwd: final.root,
93
+ json: projectCtx.json
94
+ }), options);
95
+ }
96
+
97
+ function projectFromRequest(req, url) {
98
+ const requestedRoot = url.searchParams.get('root') ||
99
+ url.searchParams.get('project') ||
100
+ req.headers['x-hcc-root'] ||
101
+ ctx.root;
102
+ const requestedDb = url.searchParams.get('db') ||
103
+ req.headers['x-hcc-db'] ||
104
+ path.join(requestedRoot, '.hello-cc', 'mesh.db');
105
+ return rememberProject(
106
+ resolveWebProjectContext(requestedRoot, requestedDb),
107
+ { activity: true }
108
+ );
109
+ }
110
+
111
+ function getSession(projectCtx, id, db = null) {
112
+ const direct = sessions.get(sessionKey(projectCtx, id));
113
+ if (direct) return direct;
114
+ for (const session of sessionsForProject(projectCtx)) {
115
+ if (session.peerId === id) return session;
116
+ }
117
+ if (db) {
118
+ for (const session of sessionsForProject(projectCtx)) {
119
+ if (resolveSessionPeerId(db, session) === id) return session;
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ function readActionToken(input, req) {
126
+ const headerToken = req.headers["x-hcc-session-token"];
127
+ return String(input.action_token || input.actionToken || headerToken || "").trim();
128
+ }
129
+
130
+ function resolveWebActionSession(projectCtx, peer, input, req) {
131
+ const db = connectWebProject(projectCtx);
132
+ let session;
133
+ try {
134
+ session = getSession(projectCtx, peer, db);
135
+ } finally {
136
+ db.close();
137
+ }
138
+ if (!session || session.status !== "running") {
139
+ throw new CliError("PEER_IDENTITY_REQUIRED", "Web peer action requires a running managed session for " + peer, { peer });
140
+ }
141
+ const actorPeer = session.peerId || peer;
142
+ if (actorPeer !== peer && session.id !== peer) {
143
+ throw new CliError("PEER_IDENTITY_MISMATCH", "Web peer action target " + peer + " does not match managed session " + actorPeer, {
144
+ peer, actor_peer: actorPeer, session_id: session.id
145
+ });
146
+ }
147
+ const provided = readActionToken(input, req);
148
+ const authorized = Boolean(provided) && [...(session.actionTokens || [])]
149
+ .some((candidate) => {
150
+ if (!tokenMatches(provided, candidate)) return false;
151
+ const socket = session.actionTokenSockets?.get(candidate);
152
+ return socket?.readyState === WebSocket.OPEN;
153
+ });
154
+ if (!authorized) {
155
+ throw new CliError("PEER_IDENTITY_REQUIRED", "Web peer action for " + peer + " requires the managed session action token", { peer });
156
+ }
157
+ return actorPeer;
158
+ }
159
+
160
+ function knownPeerIds(projectCtx) {
161
+ const db = connectWebProject(projectCtx);
162
+ try {
163
+ return db.prepare("SELECT id FROM peers").all().map((row) => row.id);
164
+ } finally {
165
+ db.close();
166
+ }
167
+ }
168
+
169
+ function nextProjectSessionId(projectCtx, kind) {
170
+ return nextSessionId([
171
+ ...sessionsForProject(projectCtx).map((session) => session.id),
172
+ ...knownPeerIds(projectCtx)
173
+ ], kind);
174
+ }
175
+
176
+ return {
177
+ projectContexts,
178
+ newSessionActionToken, rememberProject, knownProjects,
179
+ resolveWebProjectContext, connectWebProject, projectFromRequest,
180
+ getSession, readActionToken, resolveWebActionSession,
181
+ knownPeerIds, nextProjectSessionId
182
+ };
183
+ }
@@ -0,0 +1,203 @@
1
+ // PTY-backed web sessions, extracted from lib/web/runtime-main.mjs.
2
+ // startPtySession spawns node-pty children as tracked peers; the input
3
+ // normalization helpers (options, peer id, backend choice) feed both the
4
+ // PTY and tmux session factories.
5
+
6
+ import path from 'node:path';
7
+ import { CliError } from '../shared/errors.mjs';
8
+ import process from 'node:process';
9
+ import {
10
+ buildPeerCommand,
11
+ defaultSessionCommand,
12
+ hasResumeOpts
13
+ } from '../integrations/providers.mjs';
14
+ import { providerSessionPeerId } from '../core/peers/session.mjs';
15
+ import { childSessionEnv } from '../core/sessions/launch.mjs';
16
+ import { detectBranch } from '../project-context.mjs';
17
+
18
+ export function createPtySessions(deps) {
19
+ const {
20
+ pty, ctx, sessions,
21
+ broadcast, closeSessionClients, sessionKey,
22
+ connectWebProject, nextProjectSessionId,
23
+ startTmuxManagedSession,
24
+ now, addEvent, auditPayload, refreshPeerIoHeartbeat,
25
+ upsertPeer, upsertCanonicalPeerBinding,
26
+ requestActorPeer, requestSource
27
+ } = deps;
28
+
29
+ function startPtySession(input) {
30
+ const pctx = input.projectCtx || ctx;
31
+ const kind = input.kind || 'shell';
32
+ const id = input.id || nextProjectSessionId(pctx, kind);
33
+ const actorPeer = requestActorPeer(input, id);
34
+ const auditSource = requestSource(input, 'web');
35
+ const key = sessionKey(pctx, id);
36
+ if (sessions.has(key) && sessions.get(key).status === 'running') {
37
+ return sessions.get(key);
38
+ }
39
+ const role = input.role || 'peer';
40
+ const command = input.command || defaultSessionCommand(kind);
41
+ const cwd = path.resolve(input.cwd || pctx.root);
42
+ const callerEnv = input.env && typeof input.env === 'object' ? input.env : process.env;
43
+ const shell = callerEnv.SHELL || process.env.SHELL || 'bash';
44
+ const env = childSessionEnv({
45
+ HCC_PEER: id,
46
+ HCC_ROOT: pctx.root,
47
+ HCC_DB: pctx.dbPath,
48
+ TERM: 'xterm-256color'
49
+ }, callerEnv);
50
+ const size = input.size || { cols: 100, rows: 30 };
51
+ const child = pty.spawn(shell, ['-c', command], {
52
+ name: 'xterm-256color',
53
+ cols: size.cols,
54
+ rows: size.rows,
55
+ cwd,
56
+ env
57
+ });
58
+ const session = {
59
+ id,
60
+ peerId: id,
61
+ actorPeer,
62
+ auditSource,
63
+ actionTokens: new Set(),
64
+ root: pctx.root,
65
+ ctx: pctx,
66
+ kind,
67
+ role,
68
+ command,
69
+ cwd,
70
+ pid: child.pid,
71
+ pty: child,
72
+ status: 'running',
73
+ createdAt: now(),
74
+ exitedAt: null,
75
+ buffer: '',
76
+ clients: new Set()
77
+ };
78
+ sessions.set(key, session);
79
+ const db = connectWebProject(pctx);
80
+ try {
81
+ upsertPeer(db, {
82
+ id,
83
+ kind,
84
+ role,
85
+ worktree: cwd,
86
+ branch: detectBranch(cwd),
87
+ pid: child.pid,
88
+ status: 'running',
89
+ capabilities: 'web-pty'
90
+ });
91
+ const canonical = upsertCanonicalPeerBinding(db, {
92
+ peer: id,
93
+ provider: input.binding?.provider || kind,
94
+ provider_session_id: input.binding?.provider_session_id || null,
95
+ provider_session_name: input.binding?.provider_session_name || null,
96
+ resume_mode: input.binding?.resume_mode || 'new',
97
+ resume_arg: input.binding?.resume_arg || null,
98
+ command,
99
+ transport: 'web-pty',
100
+ runtime_session_id: id
101
+ }, Boolean(input.force));
102
+ session.peerId = canonical.peer;
103
+ session.binding = { ...canonical.binding };
104
+ addEvent(db, 'web.session.started', actorPeer, null, auditPayload({
105
+ actor: actorPeer,
106
+ target: session.peerId || id,
107
+ source: auditSource,
108
+ admin: actorPeer !== (session.peerId || id),
109
+ command,
110
+ cwd,
111
+ pid: child.pid
112
+ }));
113
+ } finally {
114
+ db.close();
115
+ }
116
+ child.onData((data) => {
117
+ session.buffer += data;
118
+ if (session.buffer.length > 250000) session.buffer = session.buffer.slice(-200000);
119
+ broadcast(session, { type: 'data', data });
120
+ refreshPeerIoHeartbeat(session);
121
+ });
122
+ child.onExit((event) => {
123
+ session.status = 'exited';
124
+ session.exitedAt = now();
125
+ const db = connectWebProject(pctx);
126
+ try {
127
+ db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
128
+ addEvent(db, 'web.session.exited', session.actorPeer || id, null, auditPayload({
129
+ actor: session.actorPeer || id,
130
+ target: session.peerId || id,
131
+ source: session.auditSource || 'web',
132
+ ...event
133
+ }));
134
+ } finally {
135
+ db.close();
136
+ }
137
+ broadcast(session, { type: 'exit', event });
138
+ closeSessionClients(session);
139
+ });
140
+ return session;
141
+ }
142
+
143
+ function webSessionBuildOptions(input) {
144
+ const mode = input.mode || 'new';
145
+ if (mode === 'new') return {};
146
+ if (mode === 'resume') {
147
+ const resume = String(input.resume || '').trim();
148
+ if (!resume) throw new CliError('BAD_REQUEST', 'resume session required');
149
+ return { resume };
150
+ }
151
+ if (mode === 'last') return { last: true };
152
+ if (mode === 'continue') return { continue: true };
153
+ throw new CliError('BAD_REQUEST', `Unsupported session mode: ${mode}`);
154
+ }
155
+
156
+ function webSessionPeerId(projectCtx, kind, opts, input) {
157
+ if (input.id) return input.id;
158
+ if (opts.resume) return providerSessionPeerId(kind, opts.resume);
159
+ return nextProjectSessionId(projectCtx, kind);
160
+ }
161
+
162
+ function normalizeWebSessionInput(input) {
163
+ const pctx = input.projectCtx || ctx;
164
+ const kind = input.kind || 'shell';
165
+ if (!['claude', 'codex', 'shell'].includes(kind)) {
166
+ throw new CliError('BAD_REQUEST', `Unsupported session kind: ${kind}`);
167
+ }
168
+ if (input.command || input.binding) return { ...input, kind, projectCtx: pctx };
169
+
170
+ const opts = webSessionBuildOptions(input);
171
+ if (kind === 'shell' && hasResumeOpts(opts)) {
172
+ throw new CliError('BAD_REQUEST', 'Resume modes are only supported for codex and claude sessions');
173
+ }
174
+ if (kind !== 'claude' && opts.continue) {
175
+ throw new CliError('BAD_REQUEST', 'continue is only supported for claude sessions');
176
+ }
177
+ if (kind !== 'codex' && opts.last) {
178
+ throw new CliError('BAD_REQUEST', 'last is only supported for codex sessions');
179
+ }
180
+
181
+ const id = webSessionPeerId(pctx, kind, opts, input);
182
+ const built = buildPeerCommand(id, kind, opts, []);
183
+ return {
184
+ ...input,
185
+ id,
186
+ kind,
187
+ command: built.command,
188
+ binding: built.binding,
189
+ projectCtx: pctx
190
+ };
191
+ }
192
+
193
+ function startSession(input) {
194
+ const normalized = normalizeWebSessionInput(input);
195
+ if (normalized.backend === 'pty') return startPtySession(normalized);
196
+ return startTmuxManagedSession(normalized);
197
+ }
198
+
199
+ return {
200
+ startPtySession, webSessionBuildOptions, webSessionPeerId,
201
+ normalizeWebSessionInput, startSession
202
+ };
203
+ }