@logicseek/hello-cc 1.0.1 → 1.0.2

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 (97) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +4 -0
  3. package/README.zh-CN.md +4 -0
  4. package/docs/README.md +9 -0
  5. package/docs/README.zh-CN.md +9 -0
  6. package/docs/commands.md +53 -0
  7. package/docs/commands.zh-CN.md +48 -0
  8. package/docs/dsh.md +137 -0
  9. package/docs/dsh.zh-CN.md +139 -0
  10. package/docs/mcp.md +9 -0
  11. package/docs/mcp.zh-CN.md +23 -0
  12. package/docs/native.md +295 -0
  13. package/docs/native.zh-CN.md +253 -0
  14. package/docs/web-handoff.zh-CN.md +53 -0
  15. package/lib/cli/app.mjs +25 -6
  16. package/lib/cli/commands/doctor.mjs +17 -2
  17. package/lib/cli/commands/dsh.mjs +79 -0
  18. package/lib/cli/commands/hook.mjs +241 -90
  19. package/lib/cli/commands/lock.mjs +34 -19
  20. package/lib/cli/commands/mcp.mjs +28 -0
  21. package/lib/cli/commands/native.mjs +130 -0
  22. package/lib/cli/commands/run.mjs +66 -21
  23. package/lib/cli/commands/task.mjs +6 -3
  24. package/lib/cli-args.mjs +4 -0
  25. package/lib/coordination-state.mjs +15 -1
  26. package/lib/core/coordination/event-retention.mjs +14 -2
  27. package/lib/core/coordination/gc-plan.mjs +3 -13
  28. package/lib/core/coordination/locks.mjs +45 -0
  29. package/lib/core/coordination/results.mjs +79 -0
  30. package/lib/core/peers/bindings.mjs +9 -3
  31. package/lib/diagnostics/codex.mjs +121 -0
  32. package/lib/integrations/codex-interactions.mjs +114 -0
  33. package/lib/integrations/dsh-collaboration.mjs +170 -0
  34. package/lib/integrations/dsh-cordis.d.ts +19 -0
  35. package/lib/integrations/dsh-cordis.mjs +177 -0
  36. package/lib/integrations/dsh.bundle.yml +7 -0
  37. package/lib/integrations/dsh.mjs +287 -0
  38. package/lib/integrations/native/claude.mjs +451 -0
  39. package/lib/integrations/native/codex.mjs +242 -0
  40. package/lib/integrations/native/dsh-acp.mjs +343 -0
  41. package/lib/integrations/native/index.mjs +19 -0
  42. package/lib/integrations/native/interactions.mjs +53 -0
  43. package/lib/integrations/native/jsonrpc.mjs +185 -0
  44. package/lib/integrations/peers/identity.mjs +36 -10
  45. package/lib/integrations/peers/processes.mjs +22 -8
  46. package/lib/integrations/providers.mjs +6 -2
  47. package/lib/mcp/scope.mjs +104 -0
  48. package/lib/mcp/stdio.mjs +80 -0
  49. package/lib/mcp/tools.mjs +197 -0
  50. package/lib/process/identity.mjs +3 -1
  51. package/lib/release/release-notes.mjs +9 -0
  52. package/lib/runtime/native/client.mjs +38 -0
  53. package/lib/runtime/native/service.mjs +511 -0
  54. package/lib/runtime/native/store.mjs +121 -0
  55. package/lib/ui/help.mjs +5 -2
  56. package/lib/web/adoption-state.mjs +55 -0
  57. package/lib/web/auto-attach.mjs +18 -2
  58. package/lib/web/codex-app-server.mjs +557 -0
  59. package/lib/web/codex-sessions.mjs +287 -0
  60. package/lib/web/control-lease.mjs +204 -0
  61. package/lib/web/http-routes.mjs +197 -15
  62. package/lib/web/http.mjs +5 -4
  63. package/lib/web/native-sessions.mjs +286 -0
  64. package/lib/web/project-contexts.mjs +2 -1
  65. package/lib/web/pty-sessions.mjs +60 -33
  66. package/lib/web/runtime-main.mjs +98 -6
  67. package/lib/web/session-serialize.mjs +0 -0
  68. package/lib/web/task-results.mjs +76 -0
  69. package/lib/web/tmux-sessions.mjs +92 -11
  70. package/lib/web/tmux-stream.mjs +2 -2
  71. package/lib/web/ui-codex.mjs +550 -0
  72. package/lib/web/ui-command-palette.mjs +119 -0
  73. package/lib/web/ui-handoff.mjs +102 -0
  74. package/lib/web/ui-history.mjs +118 -0
  75. package/lib/web/ui-i18n.mjs +841 -0
  76. package/lib/web/ui-interactions.mjs +78 -0
  77. package/lib/web/ui-login.mjs +101 -0
  78. package/lib/web/ui-native.mjs +165 -0
  79. package/lib/web/ui-preferences.mjs +278 -0
  80. package/lib/web/ui-review.mjs +132 -0
  81. package/lib/web/ui-template.mjs +1106 -576
  82. package/lib/web/ui-terminal-find.mjs +98 -0
  83. package/lib/web/ui-workbench.mjs +94 -0
  84. package/lib/web/ui-workspace.mjs +222 -0
  85. package/package.json +11 -3
  86. package/scripts/check-free-identifiers.mjs +67 -8
  87. package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
  88. package/scripts/dsh-cordis-acceptance.mjs +200 -0
  89. package/scripts/dsh-installed-acceptance.mjs +247 -0
  90. package/scripts/dsh-official-acceptance.mjs +180 -0
  91. package/scripts/dsh-web-startup-acceptance.mjs +87 -0
  92. package/scripts/github-release.mjs +3 -1
  93. package/scripts/native-live-acceptance.mjs +369 -0
  94. package/scripts/regression.mjs +243 -89
  95. package/scripts/web-handoff-installed-acceptance.mjs +98 -0
  96. package/scripts/web-native-interaction-acceptance.mjs +204 -0
  97. package/scripts/web-native-model-acceptance.mjs +138 -0
@@ -18,9 +18,9 @@ export function bindingFromDetected(peer, transport = 'detected') {
18
18
  export function peerBindingRuntimeRank(row) {
19
19
  if (row?.runtime_target && row.transport === 'tmux') return 50;
20
20
  if (row?.runtime_target) return 40;
21
- if (['tmux', 'web-pty'].includes(row?.transport)) return 30;
21
+ if (['tmux', 'web-pty', 'app-server'].includes(row?.transport)) return 30;
22
22
  if (row?.transport === 'hcc-run') return 20;
23
- if (row?.transport === 'hook') return 10;
23
+ if (['hook', 'cordis'].includes(row?.transport)) return 10;
24
24
  if (row?.transport === 'detected') return 5;
25
25
  return 0;
26
26
  }
@@ -41,7 +41,13 @@ export function bindingProviderSessionValue(binding) {
41
41
  }
42
42
 
43
43
  export function bindingHasRuntime(binding) {
44
- return Boolean(binding?.runtime_target) || ['tmux', 'web-pty'].includes(binding?.transport);
44
+ return Boolean(binding?.runtime_target) || ['tmux', 'web-pty', 'app-server'].includes(binding?.transport);
45
+ }
46
+
47
+ // A dsh runtime hosts independent agents. Its process/pane is evidence of
48
+ // liveness, not an interactive terminal belonging to any one agent.
49
+ export function isDshHookBinding(binding) {
50
+ return (binding?.provider || binding?.kind) === 'dsh' && ['hook', 'cordis'].includes(binding?.transport);
45
51
  }
46
52
 
47
53
  export function mergeRuntimeBinding(existing, binding) {
@@ -0,0 +1,121 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+
6
+ const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'Stop'];
7
+ const INVOCATION_TYPES = ['hook.sessionstart', 'hook.userpromptsubmit', 'hook.pretooluse', 'hook.posttooluse', 'hook.stop'];
8
+ const MAX_PROBE_BYTES = 64 * 1024;
9
+ const MAX_HOOK_BYTES = 1024 * 1024;
10
+
11
+ export function unknownCodexDiagnostics(reason = 'diagnostic_unavailable') {
12
+ return { installed: { status: 'unknown' }, version: { status: 'unknown', value: null, reason },
13
+ app_server: { status: 'unknown', startup_arguments: null, protocol_handshake: 'unknown', reason },
14
+ hooks: { configuration: { status: 'unknown', reason }, invocation: { status: 'unknown', evidence: null, reason },
15
+ stdout_delivery_receipt: { status: 'unknown', reason: 'no_explicit_stdout_delivery_receipt_is_recorded' },
16
+ provider_acceptance: 'unknown', trust: 'unknown' }, inference_called: false };
17
+ }
18
+
19
+ function probeFailure(result) {
20
+ if (result?.error?.code === 'ENOENT') return 'not_found';
21
+ if (result?.error?.code === 'ETIMEDOUT') return 'timed_out';
22
+ return 'probe_failed';
23
+ }
24
+
25
+ function runProbe(args, ctx, env, run) {
26
+ try {
27
+ return run('codex', args, { cwd: ctx.root,
28
+ env: { ...env, HCC_SHIM_NO_ATTACH: '1', HCC_SHIM_ENSURED: '1' },
29
+ encoding: 'utf8', timeout: 2000, killSignal: 'SIGKILL', maxBuffer: MAX_PROBE_BYTES,
30
+ shell: false, stdio: ['ignore', 'pipe', 'ignore'] });
31
+ } catch {
32
+ return { status: null, error: { code: 'PROBE_FAILED' } };
33
+ }
34
+ }
35
+
36
+ function isolatedProbes(ctx, env, run) {
37
+ let directory;
38
+ try {
39
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-codex-doctor-'));
40
+ const probeHome = path.join(directory, 'home');
41
+ const probeCodexHome = path.join(directory, 'codex-home');
42
+ fs.mkdirSync(probeHome); fs.mkdirSync(probeCodexHome);
43
+ // Even --version/--help can create Codex startup wrappers. Keep those
44
+ // writes away from the user's account/configuration directories.
45
+ const probeEnv = { ...env, HOME: probeHome, USERPROFILE: probeHome, CODEX_HOME: probeCodexHome };
46
+ return { version: runProbe(['--version'], ctx, probeEnv, run),
47
+ help: runProbe(['app-server', '--help'], ctx, probeEnv, run) };
48
+ } catch {
49
+ const unavailable = { status: null, error: { code: 'PROBE_FAILED' } };
50
+ return { version: unavailable, help: unavailable };
51
+ } finally {
52
+ if (directory) fs.rmSync(directory, { recursive: true, force: true });
53
+ }
54
+ }
55
+
56
+ function hooksConfiguration(env, homedir) {
57
+ const directory = env.CODEX_HOME || path.join(homedir(), '.codex');
58
+ const file = path.resolve(directory, 'hooks.json');
59
+ const result = { status: 'unknown', path: file, matched_events: [], required_events: [...HOOK_EVENTS] };
60
+ try {
61
+ const stat = fs.lstatSync(file);
62
+ if (!stat.isFile() || stat.size > MAX_HOOK_BYTES) return { ...result, reason: 'unreadable_configuration' };
63
+ // Only this hooks file is inspected. Commands and arbitrary configuration
64
+ // values never enter the diagnostic output or get executed.
65
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
66
+ for (const event of HOOK_EVENTS) {
67
+ const entries = value?.hooks?.[event];
68
+ if (Array.isArray(entries) && entries.some(entry => Array.isArray(entry?.hooks) &&
69
+ entry.hooks.some(hook => hook?.type === 'command' && typeof hook.command === 'string' &&
70
+ (/\bhcc\b.*\bhook\b/.test(hook.command) || /hello-cc.*hook/.test(hook.command))))) {
71
+ result.matched_events.push(event);
72
+ }
73
+ }
74
+ return { ...result, status: result.matched_events.length === HOOK_EVENTS.length ? 'present' : 'missing' };
75
+ } catch (error) {
76
+ return { ...result, status: error?.code === 'ENOENT' ? 'missing' : 'unknown',
77
+ reason: error?.code === 'ENOENT' ? 'file_not_found' : 'unreadable_configuration' };
78
+ }
79
+ }
80
+
81
+ function hookInvocation(db) {
82
+ const result = { status: 'unknown', evidence: null, provider_at_event: 'unknown',
83
+ scope: 'project events matched to the current Codex peer kind; historical provider identity is not verified' };
84
+ try {
85
+ const placeholders = INVOCATION_TYPES.map(() => '?').join(',');
86
+ const row = db.prepare(`SELECT e.id, e.type, e.created_at FROM events e
87
+ JOIN peers p ON p.id = e.actor WHERE p.kind = 'codex' AND e.type IN (${placeholders})
88
+ ORDER BY e.id DESC LIMIT 1`).get(...INVOCATION_TYPES);
89
+ return row ? { ...result, status: 'present', evidence: { event_id: row.id, type: row.type, created_at: row.created_at } }
90
+ : { ...result, reason: 'no_matching_invocation' };
91
+ } catch {
92
+ return { ...result, reason: 'events_unavailable' };
93
+ }
94
+ }
95
+
96
+ // Explicitly opt-in and read-only: no server/session is started, hooks are not
97
+ // executed, and no account, auth.json, config.toml or trust state is inspected.
98
+ export function diagnoseCodex(ctx, db, { env = process.env, run = spawnSync, homedir = os.homedir } = {}) {
99
+ const probes = isolatedProbes(ctx, env, run);
100
+ const versionProbe = probes.version;
101
+ const versionText = !versionProbe.error && versionProbe.status === 0 ? String(versionProbe.stdout || '').trim() : '';
102
+ const match = versionText.match(/^codex(?:-cli)?\s+(\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?)$/);
103
+ const version = match ? { status: 'known', value: match[1], evidence: 'codex --version' }
104
+ : { status: 'unknown', value: null, reason: versionText ? 'unexpected_output' : probeFailure(versionProbe) };
105
+ const helpProbe = probes.help;
106
+ const help = !helpProbe.error && helpProbe.status === 0 ? String(helpProbe.stdout || '') : '';
107
+ const advertised = /\bUsage:\s+codex\s+app-server(?:\s|$)/i.test(help);
108
+ const startup = /(?:^|\s)--listen(?=\s|[=,]|$)/m.test(help) && /stdio:\/\//.test(help)
109
+ ? ['--listen', 'stdio://'] : /(?:^|\s)--stdio(?=\s|[=,]|$)/m.test(help) ? ['--stdio'] : null;
110
+ const appServer = { status: advertised ? 'advertised' : 'unknown',
111
+ startup_arguments: advertised ? startup : null, protocol_handshake: 'unknown',
112
+ ...(advertised ? { evidence: 'codex app-server --help' }
113
+ : { reason: help ? 'unexpected_output' : probeFailure(helpProbe) }) };
114
+ return { installed: { status: match || advertised ? 'present'
115
+ : versionProbe.error?.code === 'ENOENT' && helpProbe.error?.code === 'ENOENT' ? 'missing' : 'unknown' },
116
+ version, app_server: appServer,
117
+ hooks: { configuration: hooksConfiguration(env, homedir), invocation: hookInvocation(db),
118
+ stdout_delivery_receipt: { status: 'unknown', reason: 'no_explicit_stdout_delivery_receipt_is_recorded' },
119
+ provider_acceptance: 'unknown', trust: 'unknown' },
120
+ inference_called: false };
121
+ }
@@ -0,0 +1,114 @@
1
+ // Shared validation for the official App Server command/file/permissions/input
2
+ // server requests. Human responses may narrow a request, never widen it.
3
+ export const CODEX_INTERACTION_METHODS = new Set([
4
+ 'item/commandExecution/requestApproval', 'item/fileChange/requestApproval',
5
+ 'item/permissions/requestApproval', 'item/tool/requestUserInput', 'mcpServer/elicitation/request'
6
+ ]);
7
+ // These per-thread switches expose the model tools served by our responder.
8
+ // They are experimental in Codex 0.144.6; they grant no permissions and never
9
+ // write the caller's global config. Noninteractive adapters retain defaults.
10
+ export function codexInteractiveConfig(config = {}, interactive = true) {
11
+ return { ...config, ...(interactive ? {
12
+ 'features.default_mode_request_user_input': true,
13
+ 'features.request_permissions_tool': true
14
+ } : {}) };
15
+ }
16
+ export function codexInteractionKind(method) {
17
+ return method === 'mcpServer/elicitation/request' ? 'mcp' : method === 'item/tool/requestUserInput' ? 'userInput'
18
+ : method === 'item/permissions/requestApproval' ? 'permissions' : 'approval';
19
+ }
20
+ export function cancelCodexInteraction(method) {
21
+ return method === 'mcpServer/elicitation/request' ? { action: 'cancel' } : method === 'item/tool/requestUserInput' ? { answers: {} }
22
+ : method === 'item/permissions/requestApproval' ? { permissions: {}, scope: 'turn' } : { decision: 'cancel' };
23
+ }
24
+ function fail(message) { throw Object.assign(new Error(message), { code: 'INTERACTION_RESPONSE_INVALID' }); }
25
+ const object = value => value && typeof value === 'object' && !Array.isArray(value);
26
+ function keys(value, allowed) {
27
+ if (!object(value) || Object.keys(value).some(key => !allowed.includes(key))) fail('Unexpected interaction response fields');
28
+ }
29
+ function same(a, b) {
30
+ if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => same(v, b[i]));
31
+ if (object(a) || object(b)) return object(a) && object(b) && Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(key => Object.hasOwn(b, key) && same(a[key], b[key]));
32
+ return a === b;
33
+ }
34
+ function subset(selected, requested) {
35
+ if (!Array.isArray(selected) || !Array.isArray(requested) || selected.length > requested.length ||
36
+ selected.some((entry, index) => !requested.some(value => same(entry, value)) || selected.slice(0, index).some(value => same(entry, value)))) {
37
+ fail('Granted permissions must be an exact subset of the requested permissions');
38
+ }
39
+ }
40
+ // Codex 0.144.6 hosts MCP tool approvals as an empty form elicitation.
41
+ // Return the official action/content shape. Do not persist permission in _meta,
42
+ // and do not pretend arbitrary form fields or URL authentication were answered.
43
+ function mcpResponse(request, input) {
44
+ if (!['accept', 'decline', 'cancel'].includes(input.decision)) fail('An MCP approval decision is required');
45
+ if (input.decision !== 'accept') return { action: input.decision };
46
+ const params = request.params, schema = params?.requestedSchema;
47
+ if (params?.mode !== 'form' || params?._meta?.codex_approval_kind !== 'mcp_tool_call' ||
48
+ !object(schema) || schema.type !== 'object' || !object(schema.properties) ||
49
+ Object.keys(schema.properties).length || (schema.required?.length || 0) ||
50
+ Object.keys(schema).some(key => !['type', 'properties', 'required', 'additionalProperties'].includes(key))) {
51
+ fail('Only an explicit empty-form MCP tool approval is supported; decline or cancel other elicitations');
52
+ }
53
+ return { action: 'accept', content: {} };
54
+ }
55
+
56
+ function permissionsResponse(request, input) {
57
+ if (['decline', 'cancel'].includes(input.decision)) return { permissions: {}, scope: 'turn' };
58
+ if (input.decision !== 'accept') fail('A permission decision is required');
59
+ const requested = request.params.permissions;
60
+ keys(requested, ['fileSystem', 'network']);
61
+ const selected = input.permissions;
62
+ keys(selected, ['fileSystem', 'network']);
63
+ if (!['turn', 'session'].includes(input.scope)) fail('Choose an explicit turn or session permission scope');
64
+ if (selected.network != null) {
65
+ keys(selected.network, ['enabled']);
66
+ if (selected.network.enabled !== true || requested.network?.enabled !== true) fail('Network access was not requested');
67
+ }
68
+ if (selected.fileSystem != null) {
69
+ const fs = selected.fileSystem, wanted = requested.fileSystem;
70
+ keys(fs, ['read', 'write', 'entries', 'globScanMaxDepth']);
71
+ keys(wanted, ['read', 'write', 'entries', 'globScanMaxDepth']);
72
+ for (const field of ['read', 'write', 'entries']) {
73
+ if (fs[field] != null) subset(fs[field], wanted[field]);
74
+ }
75
+ // Deny entries constrain other granted paths; dropping them would widen access.
76
+ if ((wanted.entries || []).some(entry => entry.access === 'deny' && !(fs.entries || []).some(value => same(entry, value)))) {
77
+ fail('Requested filesystem deny entries must be retained');
78
+ }
79
+ if (fs.globScanMaxDepth != null && (!Number.isSafeInteger(fs.globScanMaxDepth) || fs.globScanMaxDepth < 1 || fs.globScanMaxDepth !== wanted.globScanMaxDepth)) {
80
+ fail('Filesystem scan depth must match the request');
81
+ }
82
+ }
83
+ return { permissions: structuredClone(selected), scope: input.scope };
84
+ }
85
+ function inputResponse(request, input) {
86
+ if (input.decision === 'cancel') return { answers: {} };
87
+ const questions = request.params.questions;
88
+ if (!Array.isArray(questions) || !questions.length || questions.length > 20) fail('Questions cannot be answered safely');
89
+ const ids = questions.map(q => q?.id);
90
+ if (ids.some(id => typeof id !== 'string' || !id || id.length > 512) || new Set(ids).size !== ids.length) fail('Question identities are invalid');
91
+ keys(input.answers, ids);
92
+ if (Object.keys(input.answers).length !== ids.length) fail('Answer every question explicitly');
93
+ const answers = Object.create(null);
94
+ for (const q of questions) {
95
+ const entry = input.answers[q.id];
96
+ keys(entry, ['answers']);
97
+ if (!Array.isArray(entry.answers) || entry.answers.length !== 1 || typeof entry.answers[0] !== 'string' ||
98
+ !entry.answers[0].trim() || entry.answers[0].length > 8192) fail('Each question needs one nonempty answer');
99
+ if (q.options?.length && !q.isOther && !q.options.some(option => option.label === entry.answers[0])) fail('Choose an offered answer');
100
+ answers[q.id] = { answers: [...entry.answers] };
101
+ }
102
+ return { answers };
103
+ }
104
+ export function codexInteractionResponse(request, input) {
105
+ const granting = request.kind === 'userInput' ? input.decision !== 'cancel' : input.decision === 'accept';
106
+ if (request.truncated && granting) throw Object.assign(new Error('Interaction details are truncated; cancel or decline this request'), { code: 'INTERACTION_TRUNCATED' });
107
+ if (request.method === 'mcpServer/elicitation/request') return mcpResponse(request, input);
108
+ if (request.kind === 'permissions') return permissionsResponse(request, input);
109
+ if (request.kind === 'userInput') return inputResponse(request, input);
110
+ if (!['accept', 'decline', 'cancel'].includes(input.decision)) fail('Approval decision is invalid');
111
+ if (input.decision === 'accept' && Array.isArray(request.params.availableDecisions) &&
112
+ !request.params.availableDecisions.includes('accept')) fail('This command did not offer approval');
113
+ return { decision: input.decision };
114
+ }
@@ -0,0 +1,170 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { CliError } from '../shared/errors.mjs';
5
+ import { redactSecrets } from '../shared/redact.mjs';
6
+ import { tx } from '../db/schema.mjs';
7
+ import { createConnectionHelpers } from '../db/connection.mjs';
8
+ import { createEventHelpers } from '../db/events.mjs';
9
+ import { createPeerBindingStore } from '../db/stores/peers.mjs';
10
+ import { createPeerHelpers } from '../core/peers/peer-helpers.mjs';
11
+ import { providerSessionPeerId } from '../core/peers/session.mjs';
12
+ import { createMessageStore } from '../core/coordination/messages.mjs';
13
+ import { createTaskStore } from '../core/coordination/tasks.mjs';
14
+ import { createEvidenceRuntime } from '../core/peers/evidence-runtime.mjs';
15
+ import { createCoordinationState } from '../coordination-state.mjs';
16
+ import { createScopedMcpTools } from '../mcp/tools.mjs';
17
+ import { resolveProjectDatabase } from '../runtime/project-path.mjs';
18
+ import { registerProjectActivity } from '../runtime/projects.mjs';
19
+ import { inspectProcessIdentity } from '../process/identity.mjs';
20
+
21
+ const fail = (message) => { throw new CliError('DSH_COLLABORATION_CONFLICT', message); };
22
+
23
+ function verifiedDeadOwner(peer) {
24
+ if (!Number.isSafeInteger(peer?.pid) || peer.pid <= 0 ||
25
+ typeof peer.pid_start_token !== 'string' || !peer.pid_start_token ||
26
+ typeof peer.pid_command_hash !== 'string' || !/^[a-f0-9]{64}$/.test(peer.pid_command_hash)) return false;
27
+ const observed = inspectProcessIdentity(peer.pid);
28
+ if (observed.state === 'dead') return true;
29
+ // A recycled PID has a different start token. A changed command alone does
30
+ // not prove the original process exited (a live process may change its title).
31
+ return observed.state === 'live' && observed.identity?.pid === peer.pid &&
32
+ typeof observed.identity.startToken === 'string' &&
33
+ typeof observed.identity.commandHash === 'string' && /^[a-f0-9]{64}$/.test(observed.identity.commandHash) &&
34
+ observed.identity.startToken !== peer.pid_start_token;
35
+ }
36
+
37
+ // One in-process authority per Agent. All mutations still go through the same
38
+ // transactional business functions as the CLI and scoped MCP server.
39
+ export function createDshCollaboration({ sessionId, cwd, maxContextChars = 16000, maxToolChars = 16000 }) {
40
+ if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 1024 ||
41
+ sessionId.trim() !== sessionId || /[\u0000-\u001f\u007f]/.test(sessionId) ||
42
+ typeof cwd !== 'string' || !path.isAbsolute(cwd)) fail('Agent requires its real session ID and absolute cwd');
43
+ const project = resolveProjectDatabase({ root: cwd, createStateDir: true });
44
+ const ctx = { root: project.root, cwd: project.root, dbPath: project.db, json: true };
45
+ const peer = providerSessionPeerId('dsh', sessionId);
46
+ const owner = `cordis:${randomUUID()}`;
47
+ const now = () => Math.floor(Date.now() / 1000);
48
+ const { addEvent } = createEventHelpers({ now });
49
+ const bindings = createPeerBindingStore({ now, addEvent });
50
+ const { connect: baseConnect } = createConnectionHelpers({ now,
51
+ dedupePeerBindings: bindings.dedupePeerBindings, redactedLogText: text => redactSecrets(text) });
52
+ const connect = (context = ctx, options = {}) => baseConnect(context, { ...options, migrateRegistered: false });
53
+ const peers = createPeerHelpers({ now, addEvent });
54
+ const messages = createMessageStore({ now, addEvent });
55
+ const evidence = createEvidenceRuntime({ now });
56
+ const tasks = createTaskStore({ now, addEvent, sendMessage: messages.sendMessage,
57
+ activePeerTtl: 600, observeClockSafety: evidence.observeTaskTakeoverClockSafety });
58
+ const coordination = createCoordinationState({ connect, now, ...messages, ...tasks, ...evidence });
59
+ let disposed = false;
60
+ let retiring = false;
61
+ const identity = inspectProcessIdentity(process.pid).identity;
62
+ if (!identity) fail('Cannot establish the owning Harness process identity');
63
+
64
+ const db = connect();
65
+ try {
66
+ tx(db, () => {
67
+ const previous = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(peer);
68
+ const previousPeer = db.prepare('SELECT * FROM peers WHERE id = ?').get(peer);
69
+ if (previous || previousPeer) {
70
+ // Retain the raw ID after disposal to detect hash collisions on resume.
71
+ if (!previous || previous.provider !== 'dsh' || previous.provider_session_id !== sessionId ||
72
+ previous.provider_session_name !== null || previous.transport !== 'cordis' ||
73
+ previous.runtime_session_id !== sessionId || !previousPeer ||
74
+ !((previous.runtime_target === null && previousPeer.status === 'exited') ||
75
+ (previous.runtime_target !== null && verifiedDeadOwner(previousPeer)))) {
76
+ fail('Session already has a hooks, native, or live Cordis owner; disable the other integration first');
77
+ }
78
+ const replaced = db.prepare(`UPDATE peer_bindings SET runtime_target = ?, updated_at = ?
79
+ WHERE peer = ? AND runtime_target IS ? AND provider = 'dsh' AND provider_session_id = ?
80
+ AND transport = 'cordis' AND runtime_session_id = ?
81
+ AND EXISTS (SELECT 1 FROM peers WHERE id = ? AND status = ? AND pid IS ?
82
+ AND pid_start_token IS ? AND pid_command_hash IS ?)`)
83
+ .run(owner, now(), peer, previous.runtime_target, sessionId, sessionId, peer,
84
+ previousPeer.status, previousPeer.pid, previousPeer.pid_start_token, previousPeer.pid_command_hash);
85
+ if (replaced.changes !== 1) fail('Cordis session ownership changed during recovery');
86
+ }
87
+ peers.upsertPeer(db, { id: peer, kind: 'dsh', role: 'peer', worktree: ctx.root,
88
+ pid: process.pid, processIdentity: identity, status: 'working', capabilities: 'dsh-cordis' });
89
+ bindings.upsertPeerBinding(db, { peer, provider: 'dsh', provider_session_id: sessionId,
90
+ provider_session_name: null, resume_mode: 'session', transport: 'cordis',
91
+ runtime_session_id: sessionId, runtime_target: owner });
92
+ peers.touchPeer(db, peer, 'idle');
93
+ addEvent(db, 'dsh.cordis.opened', peer, null, { session_id: sessionId });
94
+ });
95
+ } finally { db.close(); }
96
+ registerProjectActivity(ctx);
97
+
98
+ function assertOwnership(db, allowRetiring = false) {
99
+ if (disposed || (retiring && !allowRetiring)) fail('The Cordis Agent authority has been disposed');
100
+ const row = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(peer);
101
+ if (!row || row.provider !== 'dsh' || row.transport !== 'cordis' || row.runtime_target !== owner ||
102
+ row.provider_session_id !== sessionId || row.runtime_session_id !== sessionId) fail('The Agent no longer owns this peer');
103
+ }
104
+ function owned(fn, transactional = true, allowRetiring = false) {
105
+ if (disposed || (retiring && !allowRetiring)) fail('The Cordis Agent authority has been disposed');
106
+ const db = connect(ctx, { create: false });
107
+ try {
108
+ const run = () => { assertOwnership(db, allowRetiring); return fn(db); };
109
+ return transactional ? tx(db, run) : run();
110
+ }
111
+ finally { db.close(); }
112
+ }
113
+ const authority = { scope: Object.freeze({ root: ctx.root, dbPath: ctx.dbPath, peer, executorId: owner }),
114
+ assertValid: () => { if (disposed || retiring) fail('The Cordis Agent authority has been disposed'); }, assertOwnership };
115
+ const service = createScopedMcpTools({ ctx, authority, connect, now, addEvent,
116
+ touchPeer: (db, id, status) => { assertOwnership(db); if (id !== peer) fail('Peer identity changed'); peers.touchPeer(db, id, status); } });
117
+ let queue = Promise.resolve();
118
+ function call(name, args, signal) {
119
+ const run = queue.then(async () => {
120
+ signal?.throwIfAborted();
121
+ const result = await service.call(name, args);
122
+ signal?.throwIfAborted();
123
+ const value = result.structuredContent;
124
+ const serialized = JSON.stringify(value);
125
+ return serialized.length <= maxToolChars ? JSON.parse(serialized) : {
126
+ ok: value.ok, peer, truncated: true,
127
+ notice: 'Result exceeds the configured bound. Use smaller limits; unread messages remain unread.',
128
+ preview: serialized.slice(0, Math.max(0, Math.floor((maxToolChars - 512) / 2)))
129
+ };
130
+ });
131
+ queue = run.catch(() => {});
132
+ return run;
133
+ }
134
+ function snapshot() {
135
+ return owned(db => {
136
+ const state = coordination.buildHookCoordinationContext(db, ctx, peer);
137
+ const prefix = '[hello-cc Cordis collaboration]\nUse the hcc_* tools for this Agent. They fix the project and peer; do not impersonate other sessions.\n';
138
+ const text = prefix + redactSecrets(state.text);
139
+ const complete = text.length <= maxContextChars;
140
+ return { text: complete ? text : text.slice(0, maxContextChars - 160) +
141
+ '\n[Context truncated. Read hcc_inbox/hcc_state with smaller limits. Truncated messages have not been acknowledged.]',
142
+ messages: complete ? state.messages : [], hasUnread: state.messages.length > 0, complete };
143
+ }, false);
144
+ }
145
+ return {
146
+ peer, sessionId, ctx, list: service.list, call, snapshot,
147
+ ack: items => owned(db => coordination.ackMessages(db, peer, items)),
148
+ status: status => owned(db => peers.touchPeer(db, peer, status === 'running' ? 'working' : 'idle')),
149
+ activity: (event, tool) => owned(db => {
150
+ peers.touchPeer(db, peer, 'working');
151
+ addEvent(db, event, peer, null, { tool });
152
+ }),
153
+ dispose() {
154
+ if (disposed) return;
155
+ retiring = true;
156
+ try {
157
+ owned(db => {
158
+ peers.touchPeer(db, peer, 'exited');
159
+ db.prepare('UPDATE peer_bindings SET runtime_target = NULL, updated_at = ? WHERE peer = ? AND runtime_target = ?').run(now(), peer, owner);
160
+ addEvent(db, 'dsh.cordis.closed', peer, null, { session_id: sessionId });
161
+ }, true, true);
162
+ disposed = true;
163
+ } catch (error) {
164
+ // A lost generation cannot be retried; storage failures can be.
165
+ if (error?.code === 'DSH_COLLABORATION_CONFLICT') disposed = true;
166
+ throw error;
167
+ }
168
+ }
169
+ };
170
+ }
@@ -0,0 +1,19 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm';
3
+
4
+ export interface DshCollaborationConfig {
5
+ /** Bounded model context, 2048-64000 characters. Default 16000. */
6
+ maxContextChars?: number;
7
+ /** Bounded lossless-JSON tool presentation, 2048-64000 characters. Default 16000. */
8
+ maxToolChars?: number;
9
+ }
10
+ export const name: 'hello-cc-dsh-collaboration';
11
+ export const inject: readonly ['agents', 'tools', 'sessions'];
12
+ export const DSH_CORDIS_VERSION: '0.2.0-rc.2';
13
+ export function checkDshRuntime(anchor?: string): void;
14
+ export function apply(ctx: Context, config?: DshCollaborationConfig): void;
15
+ declare module '@deepseek-ai/dsh-llm' {
16
+ interface MessageSourceMap {
17
+ 'hello-cc': { readonly kind: 'hello-cc' } & ContextFormed;
18
+ }
19
+ }
@@ -0,0 +1,177 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import process from 'node:process';
4
+ import { createRequire } from 'node:module';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { createDshCollaboration } from './dsh-collaboration.mjs';
7
+ import { scopedMcpToolDefinitions } from '../mcp/tools.mjs';
8
+
9
+ export const name = 'hello-cc-dsh-collaboration';
10
+ export const inject = ['agents', 'tools', 'sessions'];
11
+ export const DSH_CORDIS_VERSION = '0.2.0-rc.2';
12
+
13
+ const failedRetirements = new Map();
14
+ const disposedAgents = new WeakSet();
15
+
16
+ function retire(agent, state, { agentDisposed = false, contextDisposed = false }, forget) {
17
+ let entry = failedRetirements.get(agent);
18
+ if (!entry) {
19
+ entry = { state, agentDisposed, contextDisposed, forget };
20
+ failedRetirements.set(agent, entry);
21
+ } else {
22
+ if (agentDisposed) entry.agentDisposed = true;
23
+ if (contextDisposed) entry.contextDisposed = true;
24
+ }
25
+ try {
26
+ entry.state.dispose();
27
+ entry.forget();
28
+ failedRetirements.delete(agent);
29
+ } catch (error) {
30
+ if (error?.code === 'DSH_COLLABORATION_CONFLICT') {
31
+ entry.forget();
32
+ failedRetirements.delete(agent);
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+
38
+ function retryRetirements(candidate) {
39
+ let root;
40
+ try { root = fs.realpathSync(candidate.session.header.cwd); } catch { return; }
41
+ for (const [agent, entry] of failedRetirements) {
42
+ if (!(entry.agentDisposed || (entry.contextDisposed && agent === candidate)) ||
43
+ entry.state.sessionId !== candidate.session.header.id || entry.state.ctx?.root !== root) continue;
44
+ try { retire(agent, entry.state, {}, entry.forget); } catch {}
45
+ }
46
+ }
47
+
48
+ export function checkDshRuntime(anchor = process.argv[1]) {
49
+ const require = createRequire(path.resolve(anchor));
50
+ for (const pkg of ['dsh-agent', 'dsh-agent-loop', 'dsh-tools', 'dsh-session']) {
51
+ let directory = path.dirname(require.resolve(`@deepseek-ai/${pkg}`));
52
+ let found = false;
53
+ while (path.dirname(directory) !== directory) {
54
+ const file = path.join(directory, 'package.json');
55
+ if (fs.existsSync(file)) {
56
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
57
+ if (manifest.name === `@deepseek-ai/${pkg}`) {
58
+ if (manifest.version !== DSH_CORDIS_VERSION) throw new Error(`hello-cc Cordis requires ${pkg}@${DSH_CORDIS_VERSION}; found ${manifest.version}`);
59
+ found = true;
60
+ break;
61
+ }
62
+ }
63
+ directory = path.dirname(directory);
64
+ }
65
+ if (!found) throw new Error(`Cannot establish the ${pkg} runtime version`);
66
+ }
67
+ }
68
+
69
+ // Tools accepts a deliberately small JSON Schema subset. The shared service
70
+ // validates numeric/string bounds even when the presentation cannot encode them.
71
+ function toolSchema(value) {
72
+ const result = {};
73
+ for (const [key, item] of Object.entries(value)) {
74
+ if (['minimum', 'maximum', 'minLength', 'maxLength', 'maxItems'].includes(key)) continue;
75
+ if (key === 'properties') result[key] = Object.fromEntries(Object.entries(item).map(([k, v]) => [k, toolSchema(v)]));
76
+ else if (key === 'items') result[key] = toolSchema(item);
77
+ else result[key] = item;
78
+ }
79
+ return result;
80
+ }
81
+ function contextMessage(text) {
82
+ return Object.freeze({ id: randomUUID(), role: 'user',
83
+ source: Object.freeze({ kind: 'hello-cc', form: 'snapshot', sections: [Object.freeze({ name: 'coordination', text })] }),
84
+ content: Object.freeze([Object.freeze({ type: 'text', text })]) });
85
+ }
86
+
87
+ export function createDshCordisPlugin({ checkRuntime = checkDshRuntime, createCollaboration = createDshCollaboration } = {}) {
88
+ return function apply(ctx, config = {}) {
89
+ checkRuntime();
90
+ const bounds = {};
91
+ for (const key of ['maxContextChars', 'maxToolChars']) {
92
+ const value = config[key] ?? 16000;
93
+ if (!Number.isSafeInteger(value) || value < 2048 || value > 64000) throw new TypeError(`${key} must be an integer from 2048 to 64000`);
94
+ bounds[key] = value;
95
+ }
96
+ const states = new Map();
97
+ const pending = new Map();
98
+ let disposed = false;
99
+ function ensure(agent) {
100
+ if (disposed) throw new Error('hello-cc Cordis plugin has been disposed');
101
+ if (!agent?.session?.header) throw new Error('hello-cc tools require an owning Agent');
102
+ if (disposedAgents.has(agent)) throw new Error('hello-cc Cordis Agent has been disposed');
103
+ retryRetirements(agent);
104
+ let state = states.get(agent);
105
+ if (!state) {
106
+ state = createCollaboration({ sessionId: agent.session.header.id, cwd: agent.session.header.cwd, ...bounds });
107
+ states.set(agent, state);
108
+ }
109
+ return state;
110
+ }
111
+ function context(agent) {
112
+ const state = ensure(agent);
113
+ for (const [id, entry] of pending) if (entry.session === agent.session) pending.delete(id);
114
+ const snapshot = state.snapshot();
115
+ const message = contextMessage(snapshot.text);
116
+ if (snapshot.messages.length) pending.set(message.id, { session: agent.session, state, messages: snapshot.messages });
117
+ return { message, snapshot };
118
+ }
119
+ function close(agent) {
120
+ disposedAgents.add(agent);
121
+ const state = states.get(agent) || failedRetirements.get(agent)?.state;
122
+ for (const [id, entry] of pending) if (entry.session === agent.session) pending.delete(id);
123
+ if (state) retire(agent, state, { agentDisposed: true }, () => states.delete(agent));
124
+ }
125
+ // Awaited serial event; no detached setup races with the first model turn.
126
+ ctx.on('agent/created', async ({ agent, signal }) => { signal?.throwIfAborted(); ensure(agent); }, { prepend: true });
127
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
128
+ signal.throwIfAborted();
129
+ ensure(agent);
130
+ const decision = await next();
131
+ if (decision.kind !== 'enter') return decision;
132
+ signal.throwIfAborted();
133
+ const { message } = context(agent);
134
+ return { ...decision, messages: [...decision.messages, message] };
135
+ });
136
+ // ACK only after Harness commits the exact model-visible context. Rejected
137
+ // admission, failed preparation and cancelled turns leave the inbox unread.
138
+ ctx.on('session/event', (session, event) => {
139
+ if (event.type !== 'user/message') return;
140
+ const entry = pending.get(event.data.id);
141
+ if (!entry || entry.session !== session) return;
142
+ pending.delete(event.data.id);
143
+ entry.state.ack(entry.messages);
144
+ });
145
+ ctx.on('agent/status', ({ agent, status }) => { if (states.has(agent)) states.get(agent).status(status); });
146
+ ctx.on('agent/disposed', ({ agent }) => close(agent));
147
+ ctx.on('agent/turn-stopping', ({ agent, signal }) => {
148
+ signal.throwIfAborted();
149
+ const { message, snapshot } = context(agent);
150
+ if (snapshot.complete && snapshot.hasUnread) agent.steer(message);
151
+ else pending.delete(message.id);
152
+ });
153
+ ctx.on('tools/pre-execute', async (exec, next) => {
154
+ const decision = await next();
155
+ if (exec.agent && states.has(exec.agent)) states.get(exec.agent).activity('dsh.cordis.tool-observed', exec.name);
156
+ return decision;
157
+ });
158
+ // Registration is owned by this Cordis context, and is removed on disposal.
159
+ // Definitions are shared, but execution authority comes only from exec.agent.
160
+ const probe = scopedMcpToolDefinitions();
161
+ for (const tool of probe) ctx.tools.register({
162
+ name: tool.name, description: tool.description, parameters: toolSchema(tool.inputSchema),
163
+ output: { schema: {}, render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }] },
164
+ execute: async (args, exec) => ensure(exec.agent).call(tool.name, args, exec.signal)
165
+ });
166
+ ctx.on('dispose', () => {
167
+ disposed = true;
168
+ pending.clear();
169
+ for (const [agent, state] of states) {
170
+ try { retire(agent, state, { contextDisposed: true }, () => states.delete(agent)); }
171
+ catch (error) { ctx.logger?.warn(String(error)); }
172
+ }
173
+ });
174
+ };
175
+ }
176
+
177
+ export const apply = createDshCordisPlugin();
@@ -0,0 +1,7 @@
1
+ # Install this package as a dsh bundle, or use hcc dsh setup --mode cordis.
2
+ - insert:
3
+ - id: hello-cc-dsh-cordis
4
+ name: '@logicseek/hello-cc/lib/integrations/dsh-cordis.mjs'
5
+ config:
6
+ maxContextChars: 16000
7
+ maxToolChars: 16000