@logicseek/hello-cc 0.1.9 → 1.0.0
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 +59 -0
- package/README.md +18 -3
- package/README.zh-CN.md +17 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +39 -3
- package/docs/guide.zh-CN.md +34 -2
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +60 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5397 -263
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Lock CLI commands extracted from bin/hcc.mjs.
|
|
2
|
+
// Factory pattern with injected deps.
|
|
3
|
+
|
|
4
|
+
export function createLockCommands(deps) {
|
|
5
|
+
const {
|
|
6
|
+
connect,
|
|
7
|
+
now,
|
|
8
|
+
iso,
|
|
9
|
+
tx,
|
|
10
|
+
addEvent,
|
|
11
|
+
touchCurrentPeer,
|
|
12
|
+
resolveCurrentPeer,
|
|
13
|
+
parseOpts,
|
|
14
|
+
intOpt,
|
|
15
|
+
required,
|
|
16
|
+
positiveSafeIntOpt,
|
|
17
|
+
wantsHelp,
|
|
18
|
+
helpLock,
|
|
19
|
+
printResult,
|
|
20
|
+
table,
|
|
21
|
+
CliError,
|
|
22
|
+
DEFAULT_LOCK_TTL,
|
|
23
|
+
leaseDeadline,
|
|
24
|
+
scopedLockResource,
|
|
25
|
+
lockLabel,
|
|
26
|
+
lockScope,
|
|
27
|
+
lockBaseResource,
|
|
28
|
+
locksConflict,
|
|
29
|
+
clockGraceSuppressed,
|
|
30
|
+
readClockGraceUntil,
|
|
31
|
+
clockSafetyUnavailable,
|
|
32
|
+
captureLockAcquireSubject,
|
|
33
|
+
sameLockAcquireSubject,
|
|
34
|
+
observeLockOwnerEvidence,
|
|
35
|
+
observePeerEvidence,
|
|
36
|
+
prepareLockClockObservation,
|
|
37
|
+
runOptimisticEvidenceMutation,
|
|
38
|
+
assertTaskOwnerForMutation,
|
|
39
|
+
notifyTaskOwnerConflict
|
|
40
|
+
} = deps;
|
|
41
|
+
|
|
42
|
+
async function cmdLock(ctx, args) {
|
|
43
|
+
const sub = args[0];
|
|
44
|
+
if (!sub || wantsHelp(args)) return helpLock();
|
|
45
|
+
if (sub === 'acquire') return lockAcquire(ctx, args.slice(1));
|
|
46
|
+
if (sub === 'release') return lockRelease(ctx, args.slice(1));
|
|
47
|
+
if (sub === 'renew') return lockRenew(ctx, args.slice(1));
|
|
48
|
+
if (sub === 'list') return lockList(ctx, args.slice(1));
|
|
49
|
+
throw new CliError('BAD_ARGS', `Unknown lock command: ${sub}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function lockAcquire(ctx, args) {
|
|
53
|
+
const opts = parseOpts(args);
|
|
54
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
55
|
+
const peer = identity.id;
|
|
56
|
+
const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
|
|
57
|
+
const taskId = intOpt(opts, 'task', null);
|
|
58
|
+
const ttl = positiveSafeIntOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
|
|
59
|
+
const reason = opts.reason || '';
|
|
60
|
+
const acquisitionNow = now();
|
|
61
|
+
leaseDeadline(acquisitionNow, ttl);
|
|
62
|
+
const db = connect(ctx);
|
|
63
|
+
touchCurrentPeer(db, ctx, identity, 'working', 'shell');
|
|
64
|
+
let lock;
|
|
65
|
+
try {
|
|
66
|
+
const outcome = runOptimisticEvidenceMutation(db, {
|
|
67
|
+
capture: (subjectDb) => {
|
|
68
|
+
try {
|
|
69
|
+
return captureLockAcquireSubject(subjectDb, {
|
|
70
|
+
taskId,
|
|
71
|
+
requested,
|
|
72
|
+
now: acquisitionNow
|
|
73
|
+
});
|
|
74
|
+
} catch (err) {
|
|
75
|
+
throw clockSafetyUnavailable(err);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
observe: (subject) => observeLockOwnerEvidence(subject, (row, binding) =>
|
|
79
|
+
observePeerEvidence(ctx, row, binding)),
|
|
80
|
+
same: sameLockAcquireSubject,
|
|
81
|
+
beforeMutate: (subject, evidenceByOwner) =>
|
|
82
|
+
prepareLockClockObservation(db, subject, evidenceByOwner),
|
|
83
|
+
changedMessage: `Lock subjects changed while acquiring ${lockLabel(requested)}; retry`,
|
|
84
|
+
mutate: (subject, evidenceByOwner, clockObservation) => {
|
|
85
|
+
const t = subject.observedAt;
|
|
86
|
+
if (taskId) {
|
|
87
|
+
const task = subject.task;
|
|
88
|
+
if (!task) throw new CliError('NOT_FOUND', `Task #${taskId} does not exist`);
|
|
89
|
+
assertTaskOwnerForMutation(db, peer, task, 'lock-acquire');
|
|
90
|
+
}
|
|
91
|
+
const graceActive = clockGraceSuppressed(t, clockObservation.graceUntil);
|
|
92
|
+
const activeLocks = graceActive
|
|
93
|
+
? subject.locks.filter((row) =>
|
|
94
|
+
!(Number(row.expires_at) <= t && evidenceByOwner.get(row.owner)?.state === 'dead'))
|
|
95
|
+
: subject.locks.filter((row) =>
|
|
96
|
+
Number(row.expires_at) > t ||
|
|
97
|
+
evidenceByOwner.get(row.owner)?.state === 'live'
|
|
98
|
+
);
|
|
99
|
+
const conflict = activeLocks.find((row) => locksConflict(row, requested) && row.owner !== peer);
|
|
100
|
+
if (conflict) {
|
|
101
|
+
return { error: new CliError('LOCK_HELD', `Resource ${lockLabel(requested)} conflicts with lock ${lockLabel(conflict)} held by ${conflict.owner}`, {
|
|
102
|
+
resource: requested.base_resource,
|
|
103
|
+
scope: requested.scope,
|
|
104
|
+
lock_resource: conflict.resource,
|
|
105
|
+
lock_scope: lockScope(conflict),
|
|
106
|
+
owner: conflict.owner,
|
|
107
|
+
expires_at: iso(conflict.expires_at)
|
|
108
|
+
}) };
|
|
109
|
+
}
|
|
110
|
+
const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
|
|
111
|
+
const expiresAt = leaseDeadline(t, ttl);
|
|
112
|
+
db.prepare(`
|
|
113
|
+
INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec)
|
|
114
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
115
|
+
ON CONFLICT(resource) DO UPDATE SET
|
|
116
|
+
base_resource = excluded.base_resource,
|
|
117
|
+
scope = excluded.scope,
|
|
118
|
+
owner = excluded.owner,
|
|
119
|
+
task_id = excluded.task_id,
|
|
120
|
+
reason = excluded.reason,
|
|
121
|
+
expires_at = excluded.expires_at,
|
|
122
|
+
created_at = excluded.created_at,
|
|
123
|
+
ttl_sec = excluded.ttl_sec
|
|
124
|
+
`).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
|
|
125
|
+
addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null });
|
|
126
|
+
return { lock: db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource) };
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
if (outcome.error) throw outcome.error;
|
|
130
|
+
lock = outcome.lock;
|
|
131
|
+
} catch (err) {
|
|
132
|
+
notifyTaskOwnerConflict(ctx, err);
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
printResult(ctx, lock, (data) => `locked ${lockLabel(data)} by ${data.owner} until ${iso(data.expires_at)}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function lockRelease(ctx, args) {
|
|
139
|
+
const opts = parseOpts(args, { booleans: ['force'] });
|
|
140
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
141
|
+
const peer = identity.id;
|
|
142
|
+
const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
|
|
143
|
+
const db = connect(ctx);
|
|
144
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
145
|
+
const result = tx(db, () => {
|
|
146
|
+
const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
|
|
147
|
+
if (!existing) return { released: false, ...requested };
|
|
148
|
+
if (existing.owner !== peer && !opts.force) {
|
|
149
|
+
throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
150
|
+
}
|
|
151
|
+
db.prepare('DELETE FROM locks WHERE resource = ?').run(requested.resource);
|
|
152
|
+
addEvent(db, 'lock.released', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, force: Boolean(opts.force) });
|
|
153
|
+
return { released: true, ...requested };
|
|
154
|
+
});
|
|
155
|
+
printResult(ctx, result, (data) => data.released ? `released ${lockLabel(data)}` : `no lock for ${lockLabel(data)}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function lockRenew(ctx, args) {
|
|
159
|
+
const opts = parseOpts(args);
|
|
160
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
161
|
+
const peer = identity.id;
|
|
162
|
+
const requested = scopedLockResource(required(opts, 'resource'), opts.scope);
|
|
163
|
+
const ttl = positiveSafeIntOpt(opts, 'ttl', DEFAULT_LOCK_TTL);
|
|
164
|
+
const renewalNow = now();
|
|
165
|
+
const expiresAt = leaseDeadline(renewalNow, ttl);
|
|
166
|
+
const db = connect(ctx);
|
|
167
|
+
touchCurrentPeer(db, ctx, identity, 'working', 'shell');
|
|
168
|
+
const lock = tx(db, () => {
|
|
169
|
+
const existing = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
|
|
170
|
+
if (!existing) throw new CliError('NOT_FOUND', `No lock for ${lockLabel(requested)}`);
|
|
171
|
+
if (existing.owner !== peer) throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
172
|
+
db.prepare('UPDATE locks SET expires_at = ?, ttl_sec = ? WHERE resource = ?').run(expiresAt, ttl, requested.resource);
|
|
173
|
+
addEvent(db, 'lock.renewed', peer, existing.task_id || null, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl });
|
|
174
|
+
return db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource);
|
|
175
|
+
});
|
|
176
|
+
printResult(ctx, lock, (data) => `renewed ${lockLabel(data)} until ${iso(data.expires_at)}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function lockList(ctx, args) {
|
|
180
|
+
const opts = parseOpts(args, { booleans: ['all'] });
|
|
181
|
+
const db = connect(ctx);
|
|
182
|
+
const t = now();
|
|
183
|
+
const rows = opts.all || clockGraceSuppressed(t, readClockGraceUntil(db))
|
|
184
|
+
? db.prepare('SELECT * FROM locks ORDER BY resource ASC').all()
|
|
185
|
+
: db.prepare('SELECT * FROM locks WHERE expires_at > ? ORDER BY resource ASC').all(t);
|
|
186
|
+
printResult(ctx, rows, (data) => table(data, [
|
|
187
|
+
{ label: 'resource', value: (r) => lockBaseResource(r) },
|
|
188
|
+
{ label: 'scope', value: (r) => lockScope(r) },
|
|
189
|
+
{ label: 'owner', value: (r) => r.owner },
|
|
190
|
+
{ label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
|
|
191
|
+
{ label: 'expires', value: (r) => iso(r.expires_at) },
|
|
192
|
+
{ label: 'reason', value: (r) => r.reason || '' }
|
|
193
|
+
]));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { cmdLock };
|
|
197
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Small utility commands extracted from bin/hcc.mjs: down, find-root, which-real.
|
|
2
|
+
|
|
3
|
+
export function createMiscCommands(deps) {
|
|
4
|
+
const {
|
|
5
|
+
path, process, CliError, parseOpts, printResult,
|
|
6
|
+
readRuntime, runtimeRequest, runtimePath, globalRuntimePath,
|
|
7
|
+
reclaimRuntimePointerFiles, helpDown, loadSetup,
|
|
8
|
+
PRODUCT_NAME
|
|
9
|
+
} = deps;
|
|
10
|
+
|
|
11
|
+
async function cmdDown(ctx, args) {
|
|
12
|
+
if (args[0] === '--help' || args[0] === '-h') return helpDown();
|
|
13
|
+
const pointerFiles = [runtimePath(ctx), globalRuntimePath()];
|
|
14
|
+
let runtime;
|
|
15
|
+
try {
|
|
16
|
+
runtime = readRuntime(ctx);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
if (!(err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING') || process.env.HCC_RUNTIME_URL) throw err;
|
|
19
|
+
const cleanup = reclaimRuntimePointerFiles(pointerFiles, { reclaimUnknown: false });
|
|
20
|
+
if (cleanup.reclaimed < 1 || cleanup.blocked) throw err;
|
|
21
|
+
printResult(ctx, { pointers: cleanup.reclaimed }, (result) =>
|
|
22
|
+
`${PRODUCT_NAME} stale runtime pointer removed${result.pointers === 1 ? '' : 's'}`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
await runtimeRequest(ctx, 'POST', '/api/runtime/stop', {}, runtime);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (!(err instanceof CliError && err.code === 'RUNTIME_UNREACHABLE')) throw err;
|
|
29
|
+
if (runtime.source === 'env') throw err;
|
|
30
|
+
const source = path.resolve(String(runtime.source || ''));
|
|
31
|
+
const localSource = pointerFiles.find((file) => path.resolve(file) === source);
|
|
32
|
+
if (!localSource) throw err;
|
|
33
|
+
const cleanup = reclaimRuntimePointerFiles([localSource], { reclaimUnknown: false });
|
|
34
|
+
if (cleanup.reclaimed !== 1 || cleanup.blocked) throw err;
|
|
35
|
+
printResult(ctx, { runtime: localSource }, () => `${PRODUCT_NAME} stale runtime pointer removed`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
printResult(ctx, { runtime: runtime.source || runtime.base_url }, () => `${PRODUCT_NAME} runtime stopped`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function cmdFindRoot(ctx, args) {
|
|
42
|
+
const opts = parseOpts(args);
|
|
43
|
+
if (process.env.HCC_ROOT) {
|
|
44
|
+
process.stdout.write(path.resolve(process.env.HCC_ROOT) + '\n');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const root = ctx.explicitRoot ? ctx.root : path.resolve(opts.cwd || process.cwd());
|
|
48
|
+
process.stdout.write(root + '\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function cmdWhichReal(ctx, args) {
|
|
52
|
+
const name = args[0];
|
|
53
|
+
if (!name) throw new CliError('BAD_ARGS', 'Usage: hcc which-real <binary>');
|
|
54
|
+
const { findRealBinary } = await loadSetup();
|
|
55
|
+
const p = findRealBinary(name);
|
|
56
|
+
if (!p) { process.exitCode = 1; return; }
|
|
57
|
+
process.stdout.write(p + '\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { cmdDown, cmdFindRoot, cmdWhichReal };
|
|
61
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Message CLI commands extracted from bin/hcc.mjs.
|
|
2
|
+
// Factory pattern: all bin/hcc.mjs dependencies are injected.
|
|
3
|
+
|
|
4
|
+
export function createMsgCommands(deps) {
|
|
5
|
+
const {
|
|
6
|
+
connect,
|
|
7
|
+
now,
|
|
8
|
+
iso,
|
|
9
|
+
touchCurrentPeer,
|
|
10
|
+
resolveCurrentPeer,
|
|
11
|
+
registerProjectActivity,
|
|
12
|
+
parseOpts,
|
|
13
|
+
intOpt,
|
|
14
|
+
required,
|
|
15
|
+
wantsHelp,
|
|
16
|
+
helpMsg,
|
|
17
|
+
printResult,
|
|
18
|
+
table,
|
|
19
|
+
sleep,
|
|
20
|
+
CliError,
|
|
21
|
+
ackMessage,
|
|
22
|
+
getMessage,
|
|
23
|
+
queryInbox,
|
|
24
|
+
queryMessageThread,
|
|
25
|
+
sendMessage
|
|
26
|
+
} = deps;
|
|
27
|
+
|
|
28
|
+
async function cmdMsg(ctx, args) {
|
|
29
|
+
const sub = args[0];
|
|
30
|
+
if (!sub || wantsHelp(args)) return helpMsg();
|
|
31
|
+
if (sub === 'send') return msgSend(ctx, args.slice(1));
|
|
32
|
+
if (sub === 'inbox') return msgInbox(ctx, args.slice(1));
|
|
33
|
+
if (sub === 'ack') return msgAck(ctx, args.slice(1));
|
|
34
|
+
if (sub === 'reply') return msgReply(ctx, args.slice(1));
|
|
35
|
+
if (sub === 'thread') return msgThread(ctx, args.slice(1));
|
|
36
|
+
throw new CliError('BAD_ARGS', `Unknown msg command: ${sub}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function msgSend(ctx, args) {
|
|
40
|
+
const opts = parseOpts(args);
|
|
41
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
42
|
+
const sender = identity.id;
|
|
43
|
+
const recipient = opts.to || 'all';
|
|
44
|
+
const body = required(opts, 'body');
|
|
45
|
+
const taskId = intOpt(opts, 'task', null);
|
|
46
|
+
const kind = opts.kind || 'note';
|
|
47
|
+
const db = connect(ctx);
|
|
48
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
49
|
+
const id = sendMessage(db, sender, recipient, taskId, kind, body);
|
|
50
|
+
printResult(ctx, { id, sender, recipient, task_id: taskId, kind, body, reply_to: null, thread_id: id },
|
|
51
|
+
(data) => `sent message #${data.id} ${data.sender} -> ${data.recipient}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function msgInbox(ctx, args) {
|
|
55
|
+
const opts = parseOpts(args, { booleans: ['all'] });
|
|
56
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
57
|
+
const peer = identity.id;
|
|
58
|
+
const waitSec = intOpt(opts, 'wait', 0);
|
|
59
|
+
const limit = intOpt(opts, 'limit', 20);
|
|
60
|
+
const includeAll = Boolean(opts.all);
|
|
61
|
+
const db = connect(ctx);
|
|
62
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
63
|
+
const deadline = Date.now() + waitSec * 1000;
|
|
64
|
+
let rows = queryInbox(db, peer, includeAll, limit);
|
|
65
|
+
while (!rows.length && waitSec > 0 && Date.now() < deadline) {
|
|
66
|
+
await sleep(1000);
|
|
67
|
+
rows = queryInbox(db, peer, includeAll, limit);
|
|
68
|
+
}
|
|
69
|
+
printResult(ctx, rows, (data) => table(data, [
|
|
70
|
+
{ label: 'id', value: (r) => `#${r.id}` },
|
|
71
|
+
{ label: 'from', value: (r) => r.sender },
|
|
72
|
+
{ label: 'kind', value: (r) => r.kind },
|
|
73
|
+
{ label: 'task', value: (r) => r.task_id ? `#${r.task_id}` : '' },
|
|
74
|
+
{ label: 'reply', value: (r) => r.reply_to ? `#${r.reply_to}` : '' },
|
|
75
|
+
{ label: 'thread', value: (r) => r.thread_id ? `#${r.thread_id}` : '' },
|
|
76
|
+
{ label: 'time', value: (r) => iso(r.created_at) },
|
|
77
|
+
{ label: 'body', value: (r) => r.body }
|
|
78
|
+
]));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function msgAck(ctx, args) {
|
|
82
|
+
const opts = parseOpts(args);
|
|
83
|
+
const identity = resolveCurrentPeer(ctx, opts, 'peer', 'shell');
|
|
84
|
+
const peer = identity.id;
|
|
85
|
+
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
86
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
87
|
+
const db = connect(ctx);
|
|
88
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
89
|
+
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(id);
|
|
90
|
+
if (!message) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
|
|
91
|
+
ackMessage(db, peer, message);
|
|
92
|
+
printResult(ctx, { id, peer }, (data) => `acknowledged message #${data.id} for ${data.peer}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function msgReply(ctx, args) {
|
|
96
|
+
const opts = parseOpts(args);
|
|
97
|
+
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
98
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
99
|
+
const body = required(opts, 'body');
|
|
100
|
+
const db = connect(ctx);
|
|
101
|
+
const original = getMessage(db, id);
|
|
102
|
+
if (!original) throw new CliError('NOT_FOUND', `Message #${id} does not exist`);
|
|
103
|
+
const identity = resolveCurrentPeer(ctx, opts, 'from', 'shell');
|
|
104
|
+
const sender = identity.id;
|
|
105
|
+
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
106
|
+
const recipient = opts.to || original.sender;
|
|
107
|
+
const taskId = intOpt(opts, 'task', original.task_id || null);
|
|
108
|
+
const kind = opts.kind || 'reply';
|
|
109
|
+
const threadId = original.thread_id || original.id;
|
|
110
|
+
const replyId = sendMessage(db, sender, recipient, taskId, kind, body, {
|
|
111
|
+
reply_to: original.id,
|
|
112
|
+
thread_id: threadId
|
|
113
|
+
});
|
|
114
|
+
ackMessage(db, sender, original);
|
|
115
|
+
printResult(ctx, {
|
|
116
|
+
id: replyId,
|
|
117
|
+
sender,
|
|
118
|
+
recipient,
|
|
119
|
+
task_id: taskId,
|
|
120
|
+
kind,
|
|
121
|
+
body,
|
|
122
|
+
reply_to: original.id,
|
|
123
|
+
thread_id: threadId
|
|
124
|
+
}, (data) => `sent reply #${data.id} to #${data.reply_to} ${data.sender} -> ${data.recipient}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function msgThread(ctx, args) {
|
|
128
|
+
const opts = parseOpts(args);
|
|
129
|
+
const id = intOpt(opts, 'id', intOpt({ id: opts._[0] }, 'id'));
|
|
130
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing --id');
|
|
131
|
+
const limit = intOpt(opts, 'limit', 50);
|
|
132
|
+
const db = connect(ctx);
|
|
133
|
+
const data = queryMessageThread(db, id, limit);
|
|
134
|
+
printResult(ctx, data, (thread) => {
|
|
135
|
+
const lines = [`thread #${thread.thread_id} (${thread.messages.length} message${thread.messages.length === 1 ? '' : 's'})`];
|
|
136
|
+
for (const message of thread.messages) {
|
|
137
|
+
const parts = [
|
|
138
|
+
`#${message.id}`,
|
|
139
|
+
`${message.sender} -> ${message.recipient || 'all'}`,
|
|
140
|
+
message.task_id ? `task #${message.task_id}` : '',
|
|
141
|
+
message.reply_to ? `reply #${message.reply_to}` : '',
|
|
142
|
+
message.kind || 'note',
|
|
143
|
+
iso(message.created_at)
|
|
144
|
+
].filter(Boolean).join(' ');
|
|
145
|
+
lines.push(`${parts}\n ${message.body}`);
|
|
146
|
+
}
|
|
147
|
+
return lines.join('\n');
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { cmdMsg };
|
|
152
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Peer management commands extracted from bin/hcc.mjs.
|
|
2
|
+
|
|
3
|
+
export function createPeerCommands(deps) {
|
|
4
|
+
const {
|
|
5
|
+
connect, now, tx, addEvent, auditPayload, printResult, CliError, parseOpts,
|
|
6
|
+
detectBranch, buildPeerCommand, childSessionEnv, inferPeerKind,
|
|
7
|
+
findProviderSessionBinding, bindingHasRuntime,
|
|
8
|
+
resolveCurrentPeer, helpPeer, wantsHelp, CLI_NAME, cmdPeers,
|
|
9
|
+
runtimeRequest, readRuntime,
|
|
10
|
+
upsertPeer, upsertCanonicalPeerBinding, storedPeerIdentity,
|
|
11
|
+
path, process, Map, table
|
|
12
|
+
} = deps;
|
|
13
|
+
|
|
14
|
+
async function cmdPeer(ctx, args) {
|
|
15
|
+
const sub = args[0];
|
|
16
|
+
if (!sub || wantsHelp(args)) return helpPeer();
|
|
17
|
+
if (sub === 'list') return peerList(ctx, args.slice(1));
|
|
18
|
+
if (sub === 'start') return peerStart(ctx, args.slice(1));
|
|
19
|
+
if (sub === 'attach') return peerAttach(ctx, args.slice(1));
|
|
20
|
+
if (sub === 'stop') return peerStop(ctx, args.slice(1));
|
|
21
|
+
throw new CliError('BAD_ARGS', `Unknown peer command: ${sub}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function peerList(ctx, args) {
|
|
25
|
+
parseOpts(args);
|
|
26
|
+
try {
|
|
27
|
+
const data = await runtimeRequest(ctx, 'GET', '/api/sessions');
|
|
28
|
+
const db = connect(ctx);
|
|
29
|
+
let bindings;
|
|
30
|
+
try {
|
|
31
|
+
bindings = new Map(db.prepare('SELECT * FROM peer_bindings').all().map((row) => [row.peer, row]));
|
|
32
|
+
} finally {
|
|
33
|
+
db.close();
|
|
34
|
+
}
|
|
35
|
+
const rows = (data.sessions || []).map((session) => ({
|
|
36
|
+
...session,
|
|
37
|
+
binding: bindings.get(session.peer_id || session.id) || bindings.get(session.id) || null
|
|
38
|
+
}));
|
|
39
|
+
printResult(ctx, rows, (items) => table(items, [
|
|
40
|
+
{ label: 'id', value: (r) => r.id },
|
|
41
|
+
{ label: 'peer', value: (r) => r.peer_id && r.peer_id !== r.id ? r.peer_id : '' },
|
|
42
|
+
{ label: 'kind', value: (r) => r.kind },
|
|
43
|
+
{ label: 'role', value: (r) => r.role || '' },
|
|
44
|
+
{ label: 'status', value: (r) => r.status },
|
|
45
|
+
{ label: 'type', value: (r) => r.type || '' },
|
|
46
|
+
{ label: 'pane', value: (r) => r.pane || '' },
|
|
47
|
+
{ label: 'provider', value: (r) => r.binding ? r.binding.provider : '' },
|
|
48
|
+
{ label: 'resume', value: (r) => r.binding ? r.binding.resume_mode : '' },
|
|
49
|
+
{ label: 'session', value: (r) => r.binding ? (r.binding.provider_session_id || r.binding.provider_session_name || '') : '' },
|
|
50
|
+
{ label: 'pid', value: (r) => r.pid || '' },
|
|
51
|
+
{ label: 'command', value: (r) => r.command || '' }
|
|
52
|
+
]));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
if (err instanceof CliError && ['RUNTIME_NOT_RUNNING', 'RUNTIME_UNREACHABLE'].includes(err.code)) {
|
|
55
|
+
return cmdPeers(ctx, []);
|
|
56
|
+
}
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function peerStart(ctx, args) {
|
|
62
|
+
const sep = args.indexOf('--');
|
|
63
|
+
const optArgs = sep >= 0 ? args.slice(0, sep) : args;
|
|
64
|
+
const cmdArgs = sep >= 0 ? args.slice(sep + 1) : [];
|
|
65
|
+
const opts = parseOpts(optArgs, { booleans: ['last', 'continue', 'fork', 'force', 'restart-env'] });
|
|
66
|
+
const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
|
|
67
|
+
const id = opts.peer || opts._[0];
|
|
68
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
|
|
69
|
+
const firstCommand = cmdArgs[0] || '';
|
|
70
|
+
const kind = inferPeerKind(id, opts.kind, firstCommand);
|
|
71
|
+
const role = opts.role || 'peer';
|
|
72
|
+
const cwd = path.resolve(opts.cwd || ctx.root);
|
|
73
|
+
const built = buildPeerCommand(id, kind, opts, cmdArgs);
|
|
74
|
+
const command = built.command;
|
|
75
|
+
const binding = { ...built.binding, transport: 'tmux', runtime_session_id: id };
|
|
76
|
+
|
|
77
|
+
let runtime;
|
|
78
|
+
try { runtime = readRuntime(ctx); } catch (e) {
|
|
79
|
+
if (e instanceof CliError && e.code === 'RUNTIME_NOT_RUNNING') {
|
|
80
|
+
throw new CliError('RUNTIME_NOT_RUNNING',
|
|
81
|
+
`No running web runtime found. Start ${CLI_NAME} web first, then ${CLI_NAME} peer start ${id}`);
|
|
82
|
+
}
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime);
|
|
87
|
+
const db = connect(ctx);
|
|
88
|
+
try {
|
|
89
|
+
tx(db, () => {
|
|
90
|
+
if (!opts.force) {
|
|
91
|
+
const conflict = findProviderSessionBinding(db, binding);
|
|
92
|
+
if (bindingHasRuntime(conflict)) {
|
|
93
|
+
const providerSession = binding.provider_session_id || binding.provider_session_name || '';
|
|
94
|
+
throw new CliError('PROVIDER_SESSION_IN_USE', `${binding.provider} session ${providerSession} is already bound to ${conflict.peer}`, {
|
|
95
|
+
peer: conflict.peer,
|
|
96
|
+
provider: conflict.provider,
|
|
97
|
+
provider_session: providerSession
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const previousOwner = db.prepare(`
|
|
102
|
+
SELECT pid, pid_start_token, pid_command_hash
|
|
103
|
+
FROM peers WHERE id = ?
|
|
104
|
+
`).get(id) || null;
|
|
105
|
+
upsertPeer(db, {
|
|
106
|
+
id, kind, role,
|
|
107
|
+
worktree: cwd,
|
|
108
|
+
branch: detectBranch(cwd),
|
|
109
|
+
pid: previousOwner?.pid || null,
|
|
110
|
+
processIdentity: storedPeerIdentity(previousOwner),
|
|
111
|
+
status: 'starting',
|
|
112
|
+
capabilities: 'tmux'
|
|
113
|
+
});
|
|
114
|
+
addEvent(db, 'peer.start.requested', actor, null, auditPayload({
|
|
115
|
+
actor,
|
|
116
|
+
target: id,
|
|
117
|
+
peer: id,
|
|
118
|
+
source: 'cli',
|
|
119
|
+
admin: actor !== id,
|
|
120
|
+
kind,
|
|
121
|
+
role,
|
|
122
|
+
command,
|
|
123
|
+
cwd
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
db.close();
|
|
128
|
+
}
|
|
129
|
+
const data = await runtimeRequest(ctx, 'POST', '/api/sessions', {
|
|
130
|
+
id,
|
|
131
|
+
kind,
|
|
132
|
+
role,
|
|
133
|
+
command,
|
|
134
|
+
cwd,
|
|
135
|
+
binding,
|
|
136
|
+
env: childSessionEnv(),
|
|
137
|
+
restartOnEnvChange: Boolean(opts['restart-env']),
|
|
138
|
+
providerForce: Boolean(opts.force)
|
|
139
|
+
}, runtime);
|
|
140
|
+
printResult(ctx, data.session, (session) =>
|
|
141
|
+
`started ${session.id} (${session.kind}, ${session.role})${session.pane ? ` pane=${session.pane}` : ` pid=${session.pid}`}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function peerAttach(ctx, args) {
|
|
145
|
+
const opts = parseOpts(args, { booleans: ['force'] });
|
|
146
|
+
const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
|
|
147
|
+
const id = opts.peer || opts._[0];
|
|
148
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
|
|
149
|
+
const pane = opts.pane || process.env.TMUX_PANE || null;
|
|
150
|
+
const kind = opts.kind || null;
|
|
151
|
+
const role = opts.role || 'peer';
|
|
152
|
+
const cwd = opts.cwd ? path.resolve(opts.cwd) : null;
|
|
153
|
+
|
|
154
|
+
let runtime;
|
|
155
|
+
try { runtime = readRuntime(ctx); } catch (e) {
|
|
156
|
+
if (e instanceof CliError && e.code === 'RUNTIME_NOT_RUNNING') {
|
|
157
|
+
throw new CliError('RUNTIME_NOT_RUNNING',
|
|
158
|
+
`No running web runtime found. Start ${CLI_NAME} web first, then ${CLI_NAME} peer attach ${id}`);
|
|
159
|
+
}
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
await runtimeRequest(ctx, 'GET', '/api/runtime', null, runtime);
|
|
163
|
+
const db = connect(ctx);
|
|
164
|
+
try {
|
|
165
|
+
addEvent(db, 'peer.attach.requested', actor, null, auditPayload({
|
|
166
|
+
actor,
|
|
167
|
+
target: id,
|
|
168
|
+
peer: id,
|
|
169
|
+
source: 'cli',
|
|
170
|
+
admin: actor !== id,
|
|
171
|
+
pane,
|
|
172
|
+
kind,
|
|
173
|
+
role,
|
|
174
|
+
cwd
|
|
175
|
+
}));
|
|
176
|
+
} finally {
|
|
177
|
+
db.close();
|
|
178
|
+
}
|
|
179
|
+
const data = await runtimeRequest(ctx, 'POST', '/api/sessions/attach', {
|
|
180
|
+
id,
|
|
181
|
+
kind,
|
|
182
|
+
role,
|
|
183
|
+
pane,
|
|
184
|
+
cwd,
|
|
185
|
+
force: Boolean(opts.force)
|
|
186
|
+
}, runtime);
|
|
187
|
+
printResult(ctx, data.session, (session) => `attached ${session.id} (${session.kind}, ${session.role}) pane=${session.pane}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function peerStop(ctx, args) {
|
|
191
|
+
const opts = parseOpts(args);
|
|
192
|
+
const actor = resolveCurrentPeer(ctx, {}, 'peer', 'shell').id;
|
|
193
|
+
const id = opts.peer || opts._[0];
|
|
194
|
+
if (!id) throw new CliError('BAD_ARGS', 'Missing peer');
|
|
195
|
+
let data;
|
|
196
|
+
try {
|
|
197
|
+
const db = connect(ctx);
|
|
198
|
+
try {
|
|
199
|
+
addEvent(db, 'peer.stop.requested', actor, null, auditPayload({
|
|
200
|
+
actor,
|
|
201
|
+
target: id,
|
|
202
|
+
peer: id,
|
|
203
|
+
source: 'cli',
|
|
204
|
+
admin: actor !== id
|
|
205
|
+
}));
|
|
206
|
+
} finally {
|
|
207
|
+
db.close();
|
|
208
|
+
}
|
|
209
|
+
data = await runtimeRequest(ctx, 'POST', `/api/sessions/${encodeURIComponent(id)}/stop`, {});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (err instanceof CliError && err.code === 'RUNTIME_NOT_RUNNING') {
|
|
212
|
+
// No server: just update the DB to mark the peer as exited
|
|
213
|
+
const db = connect(ctx);
|
|
214
|
+
try {
|
|
215
|
+
db.prepare('UPDATE peers SET status = ?, last_seen_at = ? WHERE id = ?').run('exited', now(), id);
|
|
216
|
+
addEvent(db, 'peer.stopped', actor, null, auditPayload({
|
|
217
|
+
actor,
|
|
218
|
+
target: id,
|
|
219
|
+
peer: id,
|
|
220
|
+
admin: actor !== id
|
|
221
|
+
}));
|
|
222
|
+
} finally { db.close(); }
|
|
223
|
+
printResult(ctx, { id, status: 'exited' }, (s) => `stopped ${s.id} (no server running — DB marked exited)`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
printResult(ctx, data.session, (session) => `stopped ${session.id}`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
return { cmdPeer };
|
|
233
|
+
}
|