@fitlab-ai/agent-infra 0.8.3 → 0.8.5

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 (122) hide show
  1. package/README.zh-CN.md +10 -0
  2. package/bin/cli.ts +1 -1
  3. package/dist/bin/cli.js +1 -1
  4. package/dist/lib/cp.js +56 -15
  5. package/dist/lib/defaults.json +9 -0
  6. package/dist/lib/init.js +5 -0
  7. package/dist/lib/sandbox/capture.js +5 -3
  8. package/dist/lib/sandbox/clipboard/bridge.js +12 -9
  9. package/dist/lib/sandbox/clipboard/inbox.js +82 -0
  10. package/dist/lib/sandbox/commands/create.js +68 -26
  11. package/dist/lib/sandbox/engine.js +33 -3
  12. package/dist/lib/sandbox/engines/colima.js +2 -2
  13. package/dist/lib/sandbox/runtimes/base.dockerfile +7 -0
  14. package/dist/lib/sandbox/shell.js +9 -1
  15. package/dist/lib/sandbox/tools.js +2 -2
  16. package/dist/lib/server/daemon.js +9 -0
  17. package/dist/lib/server/process-control.js +66 -73
  18. package/dist/lib/server/process-state.js +168 -0
  19. package/dist/lib/update.js +2 -0
  20. package/lib/cp.ts +56 -15
  21. package/lib/defaults.json +9 -0
  22. package/lib/init.ts +9 -0
  23. package/lib/sandbox/capture.ts +5 -3
  24. package/lib/sandbox/clipboard/bridge.ts +14 -12
  25. package/lib/sandbox/clipboard/inbox.ts +83 -0
  26. package/lib/sandbox/commands/create.ts +80 -27
  27. package/lib/sandbox/engine.ts +37 -3
  28. package/lib/sandbox/engines/colima.ts +2 -2
  29. package/lib/sandbox/runtimes/base.dockerfile +7 -0
  30. package/lib/sandbox/shell.ts +11 -2
  31. package/lib/sandbox/tools.ts +5 -5
  32. package/lib/server/daemon.ts +9 -0
  33. package/lib/server/process-control.ts +70 -71
  34. package/lib/server/process-state.ts +183 -0
  35. package/lib/update.ts +6 -0
  36. package/package.json +2 -1
  37. package/runtime/platform-adapters/platform-sync.github.js +1501 -0
  38. package/templates/.agents/README.en.md +5 -0
  39. package/templates/.agents/README.zh-CN.md +5 -0
  40. package/templates/.agents/rules/create-issue.github.en.md +1 -1
  41. package/templates/.agents/rules/create-issue.github.zh-CN.md +1 -1
  42. package/templates/.agents/rules/review-handshake.en.md +5 -4
  43. package/templates/.agents/rules/review-handshake.zh-CN.md +5 -4
  44. package/templates/.agents/rules/testing-discipline.en.md +3 -41
  45. package/templates/.agents/rules/testing-discipline.zh-CN.md +3 -41
  46. package/templates/.agents/scripts/lib/agent-infra-package.js +149 -0
  47. package/templates/.agents/scripts/lib/post-review-commit.js +5 -0
  48. package/templates/.agents/scripts/platform-adapters/platform-sync.github.js +25 -1459
  49. package/templates/.agents/scripts/review-diff-fingerprint.js +95 -24
  50. package/templates/.agents/scripts/validate-artifact.js +144 -38
  51. package/templates/.agents/skills/analyze-task/SKILL.en.md +1 -1
  52. package/templates/.agents/skills/analyze-task/SKILL.zh-CN.md +2 -2
  53. package/templates/.agents/skills/archive-tasks/scripts/archive-tasks.sh +1 -1
  54. package/templates/.agents/skills/block-task/SKILL.en.md +1 -1
  55. package/templates/.agents/skills/block-task/SKILL.zh-CN.md +2 -2
  56. package/templates/.agents/skills/cancel-task/SKILL.en.md +1 -1
  57. package/templates/.agents/skills/cancel-task/SKILL.zh-CN.md +2 -2
  58. package/templates/.agents/skills/check-task/SKILL.zh-CN.md +9 -9
  59. package/templates/.agents/skills/close-codescan/SKILL.en.md +1 -1
  60. package/templates/.agents/skills/close-codescan/SKILL.zh-CN.md +2 -2
  61. package/templates/.agents/skills/close-dependabot/SKILL.en.md +1 -1
  62. package/templates/.agents/skills/close-dependabot/SKILL.zh-CN.md +2 -2
  63. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  64. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  65. package/templates/.agents/skills/code-task/reference/branch-management.zh-CN.md +2 -2
  66. package/templates/.agents/skills/code-task/reference/fix-mode.en.md +2 -2
  67. package/templates/.agents/skills/code-task/reference/fix-mode.zh-CN.md +2 -2
  68. package/templates/.agents/skills/code-task/reference/output-template.zh-CN.md +1 -1
  69. package/templates/.agents/skills/commit/SKILL.en.md +6 -4
  70. package/templates/.agents/skills/commit/SKILL.zh-CN.md +6 -4
  71. package/templates/.agents/skills/commit/reference/task-status-update.en.md +35 -8
  72. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +35 -8
  73. package/templates/.agents/skills/complete-manual-validation/SKILL.en.md +27 -3
  74. package/templates/.agents/skills/complete-manual-validation/SKILL.zh-CN.md +27 -3
  75. package/templates/.agents/skills/complete-task/SKILL.en.md +3 -3
  76. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +3 -3
  77. package/templates/.agents/skills/complete-task/config/verify.en.json +1 -0
  78. package/templates/.agents/skills/complete-task/config/verify.zh-CN.json +1 -0
  79. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -4
  80. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -4
  81. package/templates/.agents/skills/create-pr/reference/pr-body-template.en.md +1 -1
  82. package/templates/.agents/skills/create-pr/reference/pr-body-template.zh-CN.md +1 -1
  83. package/templates/.agents/skills/create-task/SKILL.en.md +2 -2
  84. package/templates/.agents/skills/create-task/SKILL.zh-CN.md +5 -5
  85. package/templates/.agents/skills/import-codescan/SKILL.en.md +2 -2
  86. package/templates/.agents/skills/import-codescan/SKILL.zh-CN.md +3 -3
  87. package/templates/.agents/skills/import-dependabot/SKILL.en.md +2 -2
  88. package/templates/.agents/skills/import-dependabot/SKILL.zh-CN.md +3 -3
  89. package/templates/.agents/skills/import-issue/SKILL.en.md +2 -2
  90. package/templates/.agents/skills/import-issue/SKILL.zh-CN.md +3 -3
  91. package/templates/.agents/skills/init-labels/SKILL.zh-CN.md +1 -1
  92. package/templates/.agents/skills/init-milestones/SKILL.en.md +5 -4
  93. package/templates/.agents/skills/init-milestones/SKILL.zh-CN.md +6 -5
  94. package/templates/.agents/skills/init-milestones/scripts/init-milestones.github.sh +170 -45
  95. package/templates/.agents/skills/plan-task/SKILL.en.md +1 -1
  96. package/templates/.agents/skills/plan-task/SKILL.zh-CN.md +2 -2
  97. package/templates/.agents/skills/restore-task/SKILL.en.md +1 -1
  98. package/templates/.agents/skills/restore-task/SKILL.zh-CN.md +1 -1
  99. package/templates/.agents/skills/review-analysis/SKILL.en.md +1 -1
  100. package/templates/.agents/skills/review-analysis/SKILL.zh-CN.md +1 -1
  101. package/templates/.agents/skills/review-analysis/reference/output-templates.en.md +5 -5
  102. package/templates/.agents/skills/review-analysis/reference/output-templates.zh-CN.md +5 -5
  103. package/templates/.agents/skills/review-code/SKILL.en.md +8 -4
  104. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +6 -3
  105. package/templates/.agents/skills/review-code/config/verify.en.json +3 -1
  106. package/templates/.agents/skills/review-code/config/verify.zh-CN.json +3 -1
  107. package/templates/.agents/skills/review-code/reference/output-templates.en.md +5 -5
  108. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +5 -5
  109. package/templates/.agents/skills/review-code/reference/report-template.en.md +3 -2
  110. package/templates/.agents/skills/review-code/reference/report-template.zh-CN.md +3 -2
  111. package/templates/.agents/skills/review-plan/SKILL.en.md +1 -1
  112. package/templates/.agents/skills/review-plan/SKILL.zh-CN.md +1 -1
  113. package/templates/.agents/skills/review-plan/reference/output-templates.en.md +5 -5
  114. package/templates/.agents/skills/review-plan/reference/output-templates.zh-CN.md +5 -5
  115. package/templates/.agents/skills/test/SKILL.zh-CN.md +26 -8
  116. package/templates/.agents/skills/update-agent-infra/SKILL.en.md +5 -1
  117. package/templates/.agents/skills/update-agent-infra/SKILL.zh-CN.md +6 -2
  118. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +203 -142
  119. package/templates/.agents/skills/watch-pr/SKILL.en.md +2 -2
  120. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +2 -2
  121. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +8 -1
  122. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +8 -1
@@ -44,8 +44,8 @@ function createBuiltinTools(home, project) {
44
44
  // codex churns ~/.codex/logs_2.sqlite heavily (upstream openai/codex#24275);
45
45
  // a bind-mount would write-amplify onto the host SSD via virtiofs. Mount the
46
46
  // codex home as tmpfs so those logs stay in RAM and die with the container.
47
- // Only the seeded config (config.toml, model-catalogs) is bound back over
48
- // the tmpfs; runtime files like logs_2.sqlite must stay in RAM.
47
+ // Seeded config (config.toml, model-catalogs) is copied into the tmpfs at
48
+ // startup; runtime files like logs_2.sqlite stay in RAM.
49
49
  tmpfs: { size: '512m', seed: ['config.toml', 'model-catalogs'] },
50
50
  hostLiveMounts: [
51
51
  { hostPath: hostJoin(home, '.codex', 'auth.json'), containerSubpath: 'auth.json' }
@@ -8,6 +8,7 @@ import { runAi } from './runner.js';
8
8
  import { streamCommand } from './streamer.js';
9
9
  import { markdownMessage, replyOutbound, textMessage } from './display.js';
10
10
  import { buildStatusModel, statusModelToDisplay } from '../task/commands/status.js';
11
+ import { getProcessStartTime, removePidRecordIfMatches } from './process-state.js';
11
12
  function errorMessage(error) {
12
13
  return error instanceof Error ? error.message : String(error);
13
14
  }
@@ -85,6 +86,7 @@ export async function runDaemon() {
85
86
  }
86
87
  const logger = createLogger(config.log);
87
88
  logger.info(`daemon starting agent-infra ${VERSION} pid=${process.pid}`);
89
+ const ownStartTime = getProcessStartTime(process.pid);
88
90
  const abortController = new AbortController();
89
91
  let resolveShutdown = () => { };
90
92
  const shutdown = new Promise((resolve) => {
@@ -106,6 +108,13 @@ export async function runDaemon() {
106
108
  await unloadAdapters(adapters);
107
109
  clearInterval(heartbeat);
108
110
  logger.close();
111
+ if (ownStartTime !== null) {
112
+ removePidRecordIfMatches(config.pidFile, {
113
+ version: 1,
114
+ pid: process.pid,
115
+ startTime: ownStartTime
116
+ });
117
+ }
109
118
  resolveShutdown();
110
119
  })();
111
120
  };
@@ -1,66 +1,44 @@
1
1
  import fs from 'node:fs';
2
- import path from 'node:path';
3
2
  import { spawn, execFileSync } from 'node:child_process';
4
3
  import { loadServerConfig } from './config.js';
5
4
  import { runDaemon } from './daemon.js';
5
+ import { getProcessStartTime, isProcessAlive, processIdentityMatches, readProcessState, removePidFileIfMatches, writePidRecord } from './process-state.js';
6
+ export { isProcessAlive } from './process-state.js';
6
7
  export function buildStopCommand(pid, platform) {
7
8
  if (platform === 'win32') {
8
9
  return { kind: 'exec', command: 'taskkill', args: ['/PID', String(pid), '/T', '/F'] };
9
10
  }
10
11
  return { kind: 'signal', signal: 'SIGTERM' };
11
12
  }
12
- // Liveness check that treats an exited-but-unreaped daemon as dead.
13
- //
14
- // The daemon is spawned detached and re-parented to init when `ai server start`
15
- // exits. After it terminates, `process.kill(pid, 0)` still succeeds until init
16
- // reaps the zombie. On Linux we therefore also check /proc state so status/stop
17
- // /start don't treat an already-exited daemon as running. macOS reaps orphans
18
- // via launchd and Windows has no zombies, so the kill(pid, 0) result is enough.
19
- export function isProcessAlive(pid) {
20
- try {
21
- process.kill(pid, 0);
22
- }
23
- catch (error) {
24
- // EPERM means the process exists but we may not signal it → still alive.
25
- return error.code === 'EPERM';
26
- }
27
- if (process.platform === 'linux') {
28
- try {
29
- const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf8');
30
- // The process state is the first token after the parenthesised comm.
31
- const state = stat.slice(stat.lastIndexOf(')') + 1).trim().charAt(0);
32
- if (state === 'Z')
33
- return false;
34
- }
35
- catch {
36
- return false; // /proc entry vanished → not alive
37
- }
38
- }
39
- return true;
13
+ function delay(ms) {
14
+ return new Promise((resolve) => {
15
+ setTimeout(resolve, ms);
16
+ });
40
17
  }
41
- function readPid(pidFile) {
42
- try {
43
- const raw = fs.readFileSync(pidFile, 'utf8').trim();
44
- const pid = Number.parseInt(raw, 10);
45
- return Number.isInteger(pid) && pid > 0 ? pid : null;
46
- }
47
- catch {
48
- return null;
49
- }
18
+ async function waitForProcessIdentity(pid, timeoutMs = 2000) {
19
+ const deadline = Date.now() + timeoutMs;
20
+ while (Date.now() < deadline) {
21
+ const startTime = getProcessStartTime(pid);
22
+ if (startTime !== null)
23
+ return { version: 1, pid, startTime };
24
+ await delay(25);
25
+ }
26
+ return null;
50
27
  }
51
- function removePidFile(pidFile) {
28
+ function terminateIfMatching(record) {
29
+ if (!processIdentityMatches(record))
30
+ return;
31
+ const command = buildStopCommand(record.pid, process.platform);
52
32
  try {
53
- fs.unlinkSync(pidFile);
33
+ if (command.kind === 'exec')
34
+ execFileSync(command.command, command.args);
35
+ else
36
+ process.kill(record.pid, command.signal);
54
37
  }
55
38
  catch {
56
- // Already gone.
39
+ // Best effort cleanup for a daemon whose PID record could not be published.
57
40
  }
58
41
  }
59
- function delay(ms) {
60
- return new Promise((resolve) => {
61
- setTimeout(resolve, ms);
62
- });
63
- }
64
42
  function enabledAdapterNames(config) {
65
43
  return Object.entries(config.adapters)
66
44
  .filter(([, adapter]) => adapter?.enabled === true)
@@ -69,15 +47,13 @@ function enabledAdapterNames(config) {
69
47
  export async function start({ foreground = false } = {}) {
70
48
  const config = loadServerConfig();
71
49
  const pidPath = config.pidFile;
72
- // Zombie PID cleanup: a stale PID file from a crashed daemon must not block a
73
- // fresh start.
74
- const existing = readPid(pidPath);
75
- if (existing !== null && isProcessAlive(existing)) {
76
- process.stdout.write(`server already running (pid ${existing})\n`);
50
+ const existing = readProcessState(pidPath);
51
+ if (existing.kind === 'running') {
52
+ process.stdout.write(`server already running (pid ${existing.record.pid})\n`);
77
53
  return;
78
54
  }
79
- if (existing !== null) {
80
- removePidFile(pidPath);
55
+ if (existing.kind !== 'missing') {
56
+ removePidFileIfMatches(pidPath, existing.snapshot);
81
57
  }
82
58
  if (foreground) {
83
59
  await runDaemon();
@@ -98,47 +74,64 @@ export async function start({ foreground = false } = {}) {
98
74
  if (typeof child.pid !== 'number') {
99
75
  throw new Error('server: failed to spawn daemon process');
100
76
  }
101
- fs.mkdirSync(path.dirname(pidPath), { recursive: true });
102
- fs.writeFileSync(pidPath, `${child.pid}\n`);
103
- process.stdout.write(`server started (pid ${child.pid})\n`);
77
+ // The initial check, spawn, and record publication are not a mutually
78
+ // exclusive transaction. Atomic publication keeps the record complete, but
79
+ // a separate locking design is required to serialize concurrent starts.
80
+ const record = await waitForProcessIdentity(child.pid);
81
+ if (record === null || !processIdentityMatches(record)) {
82
+ throw new Error('server: unable to verify spawned daemon process identity');
83
+ }
84
+ try {
85
+ writePidRecord(pidPath, record);
86
+ }
87
+ catch (error) {
88
+ terminateIfMatching(record);
89
+ throw error;
90
+ }
91
+ process.stdout.write(`server started (pid ${record.pid})\n`);
104
92
  }
105
93
  export async function stop() {
106
94
  const config = loadServerConfig();
107
- const pid = readPid(config.pidFile);
108
- if (pid === null) {
95
+ const state = readProcessState(config.pidFile);
96
+ if (state.kind === 'missing') {
109
97
  process.stdout.write('server is not running (no pid file)\n');
110
98
  return;
111
99
  }
112
- if (!isProcessAlive(pid)) {
113
- removePidFile(config.pidFile);
100
+ if (state.kind !== 'running') {
101
+ removePidFileIfMatches(config.pidFile, state.snapshot);
114
102
  process.stdout.write('server is not running (removed stale pid file)\n');
115
103
  return;
116
104
  }
117
- const command = buildStopCommand(pid, process.platform);
105
+ const { record } = state;
106
+ if (!processIdentityMatches(record)) {
107
+ removePidFileIfMatches(config.pidFile, state.snapshot);
108
+ process.stdout.write('server is not running (removed stale pid file)\n');
109
+ return;
110
+ }
111
+ const command = buildStopCommand(record.pid, process.platform);
118
112
  if (command.kind === 'exec') {
119
113
  execFileSync(command.command, command.args);
120
114
  }
121
115
  else {
122
- process.kill(pid, command.signal);
116
+ process.kill(record.pid, command.signal);
123
117
  const deadline = Date.now() + 5000;
124
- while (isProcessAlive(pid) && Date.now() < deadline) {
118
+ while (isProcessAlive(record.pid) && Date.now() < deadline) {
125
119
  await delay(100);
126
120
  }
127
- if (isProcessAlive(pid)) {
128
- process.kill(pid, 'SIGKILL');
121
+ if (isProcessAlive(record.pid) && processIdentityMatches(record)) {
122
+ process.kill(record.pid, 'SIGKILL');
129
123
  }
130
124
  }
131
- removePidFile(config.pidFile);
132
- process.stdout.write(`server stopped (pid ${pid})\n`);
125
+ removePidFileIfMatches(config.pidFile, state.snapshot);
126
+ process.stdout.write(`server stopped (pid ${record.pid})\n`);
133
127
  }
134
128
  export function status() {
135
129
  const config = loadServerConfig();
136
- const pid = readPid(config.pidFile);
137
- if (pid === null || !isProcessAlive(pid)) {
130
+ const state = readProcessState(config.pidFile);
131
+ if (state.kind !== 'running') {
132
+ if (state.kind !== 'missing')
133
+ removePidFileIfMatches(config.pidFile, state.snapshot);
138
134
  process.stdout.write('server: stopped\n');
139
- if (pid !== null) {
140
- process.stdout.write(` (stale pid file references pid ${pid})\n`);
141
- }
142
135
  return;
143
136
  }
144
137
  let startedAt = 'unknown';
@@ -150,7 +143,7 @@ export function status() {
150
143
  }
151
144
  const adapters = enabledAdapterNames(config);
152
145
  process.stdout.write(`server: running\n` +
153
- ` pid: ${pid}\n` +
146
+ ` pid: ${state.record.pid}\n` +
154
147
  ` started: ${startedAt}\n` +
155
148
  ` adapters: ${adapters.length > 0 ? adapters.join(', ') : '(none)'}\n` +
156
149
  ` pid file: ${config.pidFile}\n` +
@@ -0,0 +1,168 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ export function parseLinuxProcessStat(raw) {
5
+ const closeParen = raw.lastIndexOf(')');
6
+ if (closeParen < 0)
7
+ return null;
8
+ const fields = raw.slice(closeParen + 1).trim().split(/\s+/);
9
+ const state = fields[0];
10
+ const startTime = fields[19];
11
+ if (typeof state !== 'string' || state.length !== 1 || !/^\d+$/.test(startTime ?? '')) {
12
+ return null;
13
+ }
14
+ return { state, startTime: startTime };
15
+ }
16
+ export function buildProcessStartTimeQuery(pid, platform) {
17
+ if (platform === 'darwin') {
18
+ return { command: 'ps', args: ['-p', String(pid), '-o', 'lstart='] };
19
+ }
20
+ if (platform === 'win32') {
21
+ const script = `$process = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'; ` +
22
+ `if ($null -ne $process) { $process.CreationDate.ToUniversalTime().ToString('o') }`;
23
+ return {
24
+ command: 'powershell.exe',
25
+ args: ['-NoProfile', '-NonInteractive', '-Command', script]
26
+ };
27
+ }
28
+ return null;
29
+ }
30
+ export function isProcessAlive(pid, platform = process.platform) {
31
+ if (!Number.isInteger(pid) || pid <= 0)
32
+ return false;
33
+ if (platform === 'linux') {
34
+ try {
35
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
36
+ return stat !== null && stat.state !== 'Z';
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ try {
43
+ process.kill(pid, 0);
44
+ return true;
45
+ }
46
+ catch (error) {
47
+ return error.code === 'EPERM';
48
+ }
49
+ }
50
+ export function getProcessStartTime(pid, platform = process.platform) {
51
+ if (!Number.isInteger(pid) || pid <= 0)
52
+ return null;
53
+ if (platform === 'linux') {
54
+ try {
55
+ const stat = parseLinuxProcessStat(fs.readFileSync(`/proc/${pid}/stat`, 'utf8'));
56
+ return stat !== null && stat.state !== 'Z' ? stat.startTime : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ if (!isProcessAlive(pid, platform))
63
+ return null;
64
+ const query = buildProcessStartTimeQuery(pid, platform);
65
+ if (query === null)
66
+ return null;
67
+ try {
68
+ const output = execFileSync(query.command, query.args, { encoding: 'utf8' }).trim();
69
+ if (output.length === 0)
70
+ return null;
71
+ if (platform === 'win32') {
72
+ const timestamp = new Date(output);
73
+ return Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString();
74
+ }
75
+ return output;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ function parsePidRecord(raw) {
82
+ const trimmed = raw.trim();
83
+ if (/^[1-9]\d*$/.test(trimmed)) {
84
+ const pid = Number(trimmed);
85
+ return Number.isSafeInteger(pid) ? { kind: 'legacy', pid } : { kind: 'invalid' };
86
+ }
87
+ try {
88
+ const value = JSON.parse(trimmed);
89
+ if (value !== null &&
90
+ value.version === 1 &&
91
+ Number.isSafeInteger(value.pid) &&
92
+ (value.pid ?? 0) > 0 &&
93
+ typeof value.startTime === 'string' &&
94
+ value.startTime.length > 0) {
95
+ return {
96
+ kind: 'record',
97
+ record: { version: 1, pid: value.pid, startTime: value.startTime }
98
+ };
99
+ }
100
+ }
101
+ catch {
102
+ // Invalid JSON is an invalid PID record.
103
+ }
104
+ return { kind: 'invalid' };
105
+ }
106
+ export function readProcessState(pidFile) {
107
+ let snapshot;
108
+ try {
109
+ snapshot = fs.readFileSync(pidFile, 'utf8');
110
+ }
111
+ catch (error) {
112
+ if (error.code === 'ENOENT')
113
+ return { kind: 'missing', snapshot: null };
114
+ return { kind: 'invalid', snapshot: '' };
115
+ }
116
+ const parsed = parsePidRecord(snapshot);
117
+ if (parsed.kind === 'invalid')
118
+ return { kind: 'invalid', snapshot };
119
+ if (parsed.kind === 'legacy')
120
+ return { kind: 'legacy', snapshot, pid: parsed.pid };
121
+ const currentStartTime = getProcessStartTime(parsed.record.pid);
122
+ if (currentStartTime === null || currentStartTime !== parsed.record.startTime) {
123
+ return { kind: 'stale', snapshot, record: parsed.record };
124
+ }
125
+ return { kind: 'running', snapshot, record: parsed.record };
126
+ }
127
+ export function writePidRecord(pidFile, record) {
128
+ fs.mkdirSync(path.dirname(pidFile), { recursive: true });
129
+ const temporary = `${pidFile}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
130
+ try {
131
+ fs.writeFileSync(temporary, `${JSON.stringify(record)}\n`);
132
+ fs.renameSync(temporary, pidFile);
133
+ }
134
+ catch (error) {
135
+ try {
136
+ fs.unlinkSync(temporary);
137
+ }
138
+ catch {
139
+ // The temporary file was never created or was already renamed.
140
+ }
141
+ throw error;
142
+ }
143
+ }
144
+ export function removePidFileIfMatches(pidFile, expectedSnapshot) {
145
+ if (expectedSnapshot === null)
146
+ return false;
147
+ try {
148
+ if (fs.readFileSync(pidFile, 'utf8') !== expectedSnapshot)
149
+ return false;
150
+ fs.unlinkSync(pidFile);
151
+ return true;
152
+ }
153
+ catch {
154
+ return false;
155
+ }
156
+ }
157
+ export function removePidRecordIfMatches(pidFile, expected) {
158
+ const state = readProcessState(pidFile);
159
+ if (state.kind !== 'running')
160
+ return false;
161
+ if (state.record.pid !== expected.pid || state.record.startTime !== expected.startTime)
162
+ return false;
163
+ return removePidFileIfMatches(pidFile, state.snapshot);
164
+ }
165
+ export function processIdentityMatches(record) {
166
+ return getProcessStartTime(record.pid) === record.startTime;
167
+ }
168
+ //# sourceMappingURL=process-state.js.map
@@ -145,6 +145,8 @@ async function cmdUpdate() {
145
145
  // update skill
146
146
  copySkillDir(path.join(templateDir, '.agents', 'skills', 'update-agent-infra'), path.join('.agents', 'skills', 'update-agent-infra'), replacements, language, platformType);
147
147
  ok('Updated .agents/skills/update-agent-infra/');
148
+ renderFile(path.join(templateDir, '.agents', 'scripts', 'lib', 'agent-infra-package.js'), path.join('.agents', 'scripts', 'lib', 'agent-infra-package.js'), replacements);
149
+ ok('Updated .agents/scripts/lib/agent-infra-package.js');
148
150
  try {
149
151
  fs.unlinkSync(path.join('.agents', 'skills', 'update-agent-infra', 'scripts', 'sync-templates.cjs'));
150
152
  }
package/lib/cp.ts CHANGED
@@ -5,8 +5,9 @@ import { platform as currentPlatform, tmpdir as defaultTmpdir } from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { createClipboardAdapter, type ClipboardAdapter } from './sandbox/clipboard/index.ts';
8
+ import { RECEIVER_CAPABILITY, receiveRemoteClipboardPng } from './sandbox/clipboard/inbox.ts';
8
9
 
9
- const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS NSPasteboard over ssh/scp.\n';
10
+ const USAGE = 'Usage: ai cp <ssh-alias>\n\nCopy the local clipboard image (PNG) to a remote macOS clipboard or Linux sandbox over SSH.\n';
10
11
  const COMMAND_TIMEOUT_MS = 30_000;
11
12
 
12
13
  export type SpawnResult = {
@@ -65,6 +66,21 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
65
66
  writeStderr = (chunk: string) => process.stderr.write(chunk)
66
67
  } = deps;
67
68
 
69
+ if (args.length === 1 && args[0] === '--remote-receive-capability') {
70
+ if (platform === 'linux') writeStdout(`${RECEIVER_CAPABILITY}\n`);
71
+ return platform === 'linux' ? 0 : 1;
72
+ }
73
+ if (args.length === 2 && args[0] === '--remote-receive-v1') {
74
+ try {
75
+ const result = receiveRemoteClipboardPng(process.env.HOME || process.env.USERPROFILE || '', args[1]!, platform);
76
+ writeStdout(`${result}\n`);
77
+ return 0;
78
+ } catch (error) {
79
+ writeStderr(`${error instanceof Error ? error.message : 'clipboard receive failed'}\n`);
80
+ return 1;
81
+ }
82
+ }
83
+
68
84
  if (args[0] === '--help' || args[0] === '-h' || args[0] === 'help') {
69
85
  writeStdout(USAGE);
70
86
  return 0;
@@ -110,6 +126,35 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
110
126
  writeFileFn(localPng, png);
111
127
 
112
128
  remotePath = `/tmp/agent-infra-cp-${randomId()}.png`;
129
+ const remoteSystem = spawnFn('ssh', [
130
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'uname', '-s'
131
+ ]);
132
+ if (remoteSystem.status !== 0) {
133
+ writeStderr(`failed to detect remote platform on ${alias}:\n${commandDetail(remoteSystem)}\n`);
134
+ return 1;
135
+ }
136
+ const remotePlatform = remoteSystem.stdout.trim();
137
+ if (remotePlatform !== 'Darwin' && remotePlatform !== 'Linux') {
138
+ writeStderr(`unsupported remote platform on ${alias}: ${remotePlatform || 'unknown'}\n`);
139
+ return 1;
140
+ }
141
+ if (remotePlatform === 'Linux') {
142
+ const homeCheck = spawnFn('ssh', [
143
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'test', '-d', '$HOME/.agent-infra'
144
+ ]);
145
+ if (homeCheck.status !== 0) {
146
+ writeStderr(`remote agent-infra installation is missing on ${alias}\n`);
147
+ return 1;
148
+ }
149
+ const capability = spawnFn('ssh', [
150
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-capability'
151
+ ]);
152
+ if (capability.status !== 0 || capability.stderr !== '' || capability.stdout.replace(/\n$/u, '') !== RECEIVER_CAPABILITY) {
153
+ writeStderr(`remote agent-infra clipboard receiver is unavailable or incompatible on ${alias}; update agent-infra\n`);
154
+ return 1;
155
+ }
156
+ }
157
+
113
158
  const upload = spawnFn('scp', [
114
159
  '-o',
115
160
  'BatchMode=yes',
@@ -124,25 +169,21 @@ export async function cmdCp(args: string[], deps: CpDeps = {}): Promise<number>
124
169
  }
125
170
  uploaded = true;
126
171
 
127
- // Remote write currently targets macOS only: it pipes an AppleScript to the
128
- // remote `osascript` to set its NSPasteboard. This is the extension point for
129
- // other remote platforms later (e.g. dispatch on remote OS to wl-copy/xclip
130
- // on Linux); a non-macOS remote fails here with a clear non-zero error today.
131
- const setRemote = spawnFn('ssh', [
132
- '-o',
133
- 'BatchMode=yes',
134
- '-o',
135
- 'ConnectTimeout=10',
136
- alias,
137
- 'osascript',
138
- '-'
139
- ], remoteSetScript(remotePath));
172
+ const setRemote = remotePlatform === 'Darwin'
173
+ ? spawnFn('ssh', [
174
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'osascript', '-'
175
+ ], remoteSetScript(remotePath))
176
+ : spawnFn('ssh', [
177
+ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', alias, 'ai', 'cp', '--remote-receive-v1', remotePath
178
+ ]);
140
179
  if (setRemote.status !== 0) {
141
180
  writeStderr(`failed to set remote clipboard on ${alias}:\n${commandDetail(setRemote)}\n`);
142
181
  return 1;
143
182
  }
144
183
 
145
- writeStdout(`copied clipboard image to ${alias}\n`);
184
+ writeStdout(remotePlatform === 'Linux'
185
+ ? `uploaded clipboard image to ${alias}; return to the SSH session and press Ctrl+V\n`
186
+ : `copied clipboard image to ${alias}\n`);
146
187
  return 0;
147
188
  } finally {
148
189
  if (uploaded && remotePath) {
package/lib/defaults.json CHANGED
@@ -44,14 +44,23 @@
44
44
  ".gemini/commands/",
45
45
  ".git-hooks/check-version-format.sh",
46
46
  ".github/scripts/",
47
+ ".github/workflows/metadata-sync.yml",
48
+ ".github/workflows/pr-label.yml",
49
+ ".github/workflows/status-label.yml",
47
50
  ".opencode/commands/"
48
51
  ],
52
+ "guardedManaged": [
53
+ ".github/workflows/metadata-sync.yml",
54
+ ".github/workflows/pr-label.yml",
55
+ ".github/workflows/status-label.yml"
56
+ ],
49
57
  "merged": [
50
58
  "**/post-release.*",
51
59
  "**/release.*",
52
60
  "**/test-integration.*",
53
61
  "**/test.*",
54
62
  "**/upgrade-dependency.*",
63
+ ".agents/rules/testing-discipline.*",
55
64
  ".agents/skills/post-release/SKILL.*",
56
65
  ".agents/skills/release/SKILL.*",
57
66
  ".agents/skills/test-integration/SKILL.*",
package/lib/init.ts CHANGED
@@ -278,6 +278,12 @@ async function cmdInit(): Promise<void> {
278
278
  platformType
279
279
  );
280
280
  ok('Installed .agents/skills/update-agent-infra/');
281
+ renderFile(
282
+ path.join(templateDir, '.agents', 'scripts', 'lib', 'agent-infra-package.js'),
283
+ path.join('.agents', 'scripts', 'lib', 'agent-infra-package.js'),
284
+ replacements
285
+ );
286
+ ok('Installed .agents/scripts/lib/agent-infra-package.js');
281
287
 
282
288
  // install Claude command (only if enabled)
283
289
  if (enabledTUISet.has('claude-code')) {
@@ -347,6 +353,9 @@ async function cmdInit(): Promise<void> {
347
353
  console.log('');
348
354
  ok('Project initialized successfully!');
349
355
  console.log('');
356
+ console.log(' If this init used npx, install agent-infra persistently before update or validation:');
357
+ console.log(' npm install -g @fitlab-ai/agent-infra');
358
+ console.log('');
350
359
  if (enabledTUISet.size === 0) {
351
360
  console.log(' No built-in TUI selected.');
352
361
  console.log(` Configure "customTUIs" in ${configPath} before running update-agent-infra.`);
@@ -2,6 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { loadConfig } from './config.ts';
3
3
  import { containerNameCandidates, sandboxBranchLabel, sandboxLabel } from './constants.ts';
4
4
  import { detectEngine } from './engine.ts';
5
+ import { commandForEngine } from './shell.ts';
5
6
  import { hostTimezoneEnvFlags, terminalEnvFlags } from './commands/enter.ts';
6
7
  import {
7
8
  fetchSandboxRows,
@@ -95,12 +96,12 @@ function buildRunScript(params: {
95
96
  return `#!/bin/sh
96
97
  set -u
97
98
  cd /workspace
98
- date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/started_at
99
+ date "+%Y-%m-%d %H:%M:%S%z" | sed 's/\\([+-][0-9][0-9]\\)\\([0-9][0-9]\\)$/\\1:\\2/' > ${runDir}/started_at
99
100
  printf '%s\\n' running > ${runDir}/status
100
101
  ${command}
101
102
  code=$?
102
103
  printf '%s\\n' "$code" > ${runDir}/exit_code
103
- date "+%Y-%m-%d %H:%M:%S%:z" > ${runDir}/finished_at
104
+ date "+%Y-%m-%d %H:%M:%S%z" | sed 's/\\([+-][0-9][0-9]\\)\\([0-9][0-9]\\)$/\\1:\\2/' > ${runDir}/finished_at
104
105
  if [ "$code" -eq 0 ]; then
105
106
  printf '%s\\n' completed > ${runDir}/status
106
107
  else
@@ -202,7 +203,8 @@ export async function runInSandbox(
202
203
  '-lc',
203
204
  buildTmuxLauncher({ request, runId })
204
205
  ];
205
- const result = await (options.spawn ?? spawnCapture)('docker', dockerArgs);
206
+ const command = commandForEngine(engine, 'docker', dockerArgs);
207
+ const result = await (options.spawn ?? spawnCapture)(command.cmd, command.args);
206
208
  return {
207
209
  ...result,
208
210
  run: {