farming-code 2.2.18 → 2.2.19

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.
@@ -1414,6 +1414,16 @@ class AgentManager extends EventEmitter {
1414
1414
  projectOrder: finiteOrder(record.projectOrder),
1415
1415
  pinnedOrder: finiteOrder(record.pinnedOrder),
1416
1416
  customTitle: record.customTitle || '',
1417
+ pinned: record.pinned === true,
1418
+ attentionSeq: finiteNonNegativeInteger(record.attentionSeq),
1419
+ readAttentionSeq: finiteNonNegativeInteger(record.readAttentionSeq),
1420
+ attentionUpdatedAt: finiteNumberOrNull(record.attentionUpdatedAt),
1421
+ readAttentionAt: finiteNumberOrNull(record.readAttentionAt),
1422
+ attentionReason: record.attentionReason || '',
1423
+ attentionOutputEpoch: record.attentionOutputEpoch || '',
1424
+ attentionOutputSeq: finiteNumberOrNull(record.attentionOutputSeq),
1425
+ readOutputEpoch: record.readOutputEpoch || '',
1426
+ readOutputSeq: finiteNumberOrNull(record.readOutputSeq),
1417
1427
  persistentSessionId: record.id || '',
1418
1428
  runtimeAgentId: record.runtimeAgentId || '',
1419
1429
  reviveTerminalState: record.serializedState || null,
@@ -1461,7 +1471,10 @@ class AgentManager extends EventEmitter {
1461
1471
  replacement.attentionUpdatedAt = finiteNumberOrNull(record.attentionUpdatedAt);
1462
1472
  replacement.readAttentionAt = finiteNumberOrNull(record.readAttentionAt);
1463
1473
  replacement.attentionReason = record.attentionReason || '';
1474
+ replacement.attentionOutputEpoch = record.attentionOutputEpoch || '';
1464
1475
  replacement.attentionOutputSeq = finiteNumberOrNull(record.attentionOutputSeq);
1476
+ replacement.readOutputEpoch = record.readOutputEpoch || '';
1477
+ replacement.readOutputSeq = finiteNumberOrNull(record.readOutputSeq);
1465
1478
  replacement.unread = agentAttentionUnread(replacement);
1466
1479
  this.ensurePersistentAgentSession(replacement);
1467
1480
  if (sessionKey) liveProviderSessions.add(sessionKey);
@@ -1737,6 +1750,7 @@ class AgentManager extends EventEmitter {
1737
1750
  if (!agent || !this.configManager || typeof this.configManager.ensureAgentSessionRecord !== 'function') {
1738
1751
  return '';
1739
1752
  }
1753
+ const previousPersistentSessionId = agent.persistentSessionId || '';
1740
1754
  const sessionOptions = agent.providerSessionKey
1741
1755
  ? this.acpSessionOptionsByKey.get(agent.providerSessionKey)
1742
1756
  : null;
@@ -1747,9 +1761,39 @@ class AgentManager extends EventEmitter {
1747
1761
  } : {}),
1748
1762
  ...patch,
1749
1763
  });
1750
- if (persistentSessionId && !agent.persistentSessionId) {
1764
+ if (persistentSessionId) {
1751
1765
  agent.persistentSessionId = persistentSessionId;
1752
1766
  }
1767
+ if (
1768
+ persistentSessionId
1769
+ && previousPersistentSessionId
1770
+ && persistentSessionId !== previousPersistentSessionId
1771
+ && agent.providerSessionKey
1772
+ && typeof this.configManager.getAgentSessionRecordForProviderSessionKey === 'function'
1773
+ ) {
1774
+ const record = this.configManager.getAgentSessionRecordForProviderSessionKey(agent.providerSessionKey);
1775
+ if (record) {
1776
+ agent.projectWorkspace = record.projectWorkspace || agent.projectWorkspace || '';
1777
+ agent.task = typeof record.task === 'string' ? record.task : (agent.task || '');
1778
+ agent.workflowTemplate = typeof record.workflowTemplate === 'string'
1779
+ ? record.workflowTemplate
1780
+ : (agent.workflowTemplate || '');
1781
+ agent.customTitle = typeof record.customTitle === 'string' ? record.customTitle : '';
1782
+ agent.pinned = record.pinned === true;
1783
+ agent.projectOrder = finiteOrder(record.projectOrder);
1784
+ agent.pinnedOrder = finiteOrder(record.pinnedOrder);
1785
+ agent.attentionSeq = finiteNonNegativeInteger(record.attentionSeq);
1786
+ agent.readAttentionSeq = finiteNonNegativeInteger(record.readAttentionSeq);
1787
+ agent.attentionUpdatedAt = finiteNumberOrNull(record.attentionUpdatedAt);
1788
+ agent.readAttentionAt = finiteNumberOrNull(record.readAttentionAt);
1789
+ agent.attentionReason = record.attentionReason || '';
1790
+ agent.attentionOutputEpoch = record.attentionOutputEpoch || '';
1791
+ agent.attentionOutputSeq = finiteNumberOrNull(record.attentionOutputSeq);
1792
+ agent.readOutputEpoch = record.readOutputEpoch || '';
1793
+ agent.readOutputSeq = finiteNumberOrNull(record.readOutputSeq);
1794
+ agent.unread = agentAttentionUnread(agent);
1795
+ }
1796
+ }
1753
1797
  return persistentSessionId;
1754
1798
  }
1755
1799
 
@@ -2828,19 +2872,19 @@ class AgentManager extends EventEmitter {
2828
2872
  shellLastCommandStartedAt: null,
2829
2873
  shellLastCommandFinishedAt: null,
2830
2874
  shellLastCommandDurationMs: null,
2831
- pinned: false,
2875
+ pinned: options.pinned === true,
2832
2876
  projectOrder: finiteOrder(options.projectOrder),
2833
2877
  pinnedOrder: finiteOrder(options.pinnedOrder),
2834
- attentionSeq: 0,
2835
- readAttentionSeq: 0,
2836
- attentionUpdatedAt: null,
2837
- readAttentionAt: null,
2838
- attentionReason: '',
2839
- attentionOutputEpoch: '',
2840
- attentionOutputSeq: null,
2841
- readOutputEpoch: '',
2842
- readOutputSeq: null,
2843
- unread: false,
2878
+ attentionSeq: finiteNonNegativeInteger(options.attentionSeq),
2879
+ readAttentionSeq: finiteNonNegativeInteger(options.readAttentionSeq),
2880
+ attentionUpdatedAt: finiteNumberOrNull(options.attentionUpdatedAt),
2881
+ readAttentionAt: finiteNumberOrNull(options.readAttentionAt),
2882
+ attentionReason: typeof options.attentionReason === 'string' ? options.attentionReason : '',
2883
+ attentionOutputEpoch: typeof options.attentionOutputEpoch === 'string' ? options.attentionOutputEpoch : '',
2884
+ attentionOutputSeq: finiteNumberOrNull(options.attentionOutputSeq),
2885
+ readOutputEpoch: typeof options.readOutputEpoch === 'string' ? options.readOutputEpoch : '',
2886
+ readOutputSeq: finiteNumberOrNull(options.readOutputSeq),
2887
+ unread: finiteNonNegativeInteger(options.attentionSeq) > finiteNonNegativeInteger(options.readAttentionSeq),
2844
2888
  archived: false,
2845
2889
  archivedAt: null,
2846
2890
  canForkNewWorktree: this.canCreateForkWorktree(projectWorkspace || workspace),
@@ -3102,6 +3146,9 @@ class AgentManager extends EventEmitter {
3102
3146
  agentRecord.persistentSessionId = this.ensurePersistentAgentSession(agentRecord, {
3103
3147
  visibleOnMainPage: true,
3104
3148
  archived: false,
3149
+ ...(options.customTitleExplicit === true
3150
+ ? { customTitle: agentRecord.customTitle }
3151
+ : {}),
3105
3152
  });
3106
3153
 
3107
3154
  const agent = this.agents.get(agentId);
@@ -3717,7 +3764,7 @@ class AgentManager extends EventEmitter {
3717
3764
 
3718
3765
  const customTitle = String(title || '').trim().slice(0, 80);
3719
3766
  agent.customTitle = customTitle;
3720
- this.ensurePersistentAgentSession(agent);
3767
+ this.ensurePersistentAgentSession(agent, { customTitle });
3721
3768
  this.updateEngineProviderSessionMetadata(agent);
3722
3769
  this.emit('update');
3723
3770
  return { agentId, customTitle };
@@ -4379,24 +4426,33 @@ class AgentManager extends EventEmitter {
4379
4426
  }
4380
4427
 
4381
4428
  async rollbackPermanentWorktree(created) {
4382
- if (!created?.workspace || !created?.sourceWorkspace) return;
4429
+ if (!created?.workspace || !created?.sourceWorkspace) {
4430
+ return { rolledBack: true };
4431
+ }
4432
+ const errors = [];
4383
4433
  try {
4384
4434
  await execFileAsync('git', ['-C', created.sourceWorkspace, 'worktree', 'remove', '--force', created.workspace], {
4385
4435
  timeout: 60000,
4386
4436
  maxBuffer: 1024 * 1024 * 4,
4387
4437
  });
4388
- } catch {
4389
- // Rollback is best-effort so the original create/persist failure remains visible.
4438
+ } catch (error) {
4439
+ const message = error && error.stderr ? String(error.stderr).trim() : '';
4440
+ errors.push(message || 'Failed to remove the created worktree');
4390
4441
  }
4391
- if (!created.branch) return;
4392
- try {
4393
- await execFileAsync('git', ['-C', created.sourceWorkspace, 'branch', '-D', created.branch], {
4394
- timeout: 30000,
4395
- maxBuffer: 1024 * 1024 * 4,
4396
- });
4397
- } catch {
4398
- // The worktree removal may already have pruned the newly created branch.
4442
+ if (created.branch) {
4443
+ try {
4444
+ await execFileAsync('git', ['-C', created.sourceWorkspace, 'branch', '-D', created.branch], {
4445
+ timeout: 30000,
4446
+ maxBuffer: 1024 * 1024 * 4,
4447
+ });
4448
+ } catch (error) {
4449
+ const message = error && error.stderr ? String(error.stderr).trim() : '';
4450
+ errors.push(message || 'Failed to delete the created worktree branch');
4451
+ }
4399
4452
  }
4453
+ return errors.length > 0
4454
+ ? { rolledBack: false, error: errors.join('; ') }
4455
+ : { rolledBack: true };
4400
4456
  }
4401
4457
 
4402
4458
  async inspectForkWorktreeProject(workspace) {
@@ -4498,13 +4554,6 @@ class AgentManager extends EventEmitter {
4498
4554
  }
4499
4555
 
4500
4556
  const projectAgents = this.agentsForProjectWorkspace(inspected.workspace);
4501
- const removedMainPageSessionKeys = this.removeMainPageProviderSessionsForAgents(projectAgents);
4502
- const archivedAgentIds = [];
4503
- for (const agent of projectAgents) {
4504
- const result = await this.archiveAgent(agent.id);
4505
- if (result && !result.error) archivedAgentIds.push(agent.id);
4506
- }
4507
-
4508
4557
  const args = ['-C', inspected.workspace, 'worktree', 'remove'];
4509
4558
  if (options.force === true) args.push('--force');
4510
4559
  args.push(inspected.workspace);
@@ -4518,18 +4567,31 @@ class AgentManager extends EventEmitter {
4518
4567
  const message = error && error.stderr ? String(error.stderr).trim() : '';
4519
4568
  return {
4520
4569
  ...inspected,
4521
- archivedAgentIds,
4522
- removedMainPageSessionKeys,
4523
4570
  error: message || 'Failed to delete git worktree',
4524
4571
  };
4525
4572
  }
4526
4573
 
4574
+ const archivedAgentIds = [];
4575
+ const removedMainPageSessionKeys = [];
4576
+ const cleanupErrors = [];
4577
+ for (const agent of projectAgents) {
4578
+ const result = await this.archiveAgent(agent.id, { requireEngineExit: true });
4579
+ if (result && !result.error) {
4580
+ archivedAgentIds.push(agent.id);
4581
+ removedMainPageSessionKeys.push(...(result.removedMainPageSessionKeys || []));
4582
+ } else {
4583
+ cleanupErrors.push(`${agent.id}: ${result?.error || 'Failed to archive Agent'}`);
4584
+ }
4585
+ }
4527
4586
  return {
4528
4587
  workspace: inspected.workspace,
4529
4588
  deleted: true,
4530
4589
  forced: options.force === true,
4531
4590
  archivedAgentIds,
4532
- removedMainPageSessionKeys,
4591
+ removedMainPageSessionKeys: Array.from(new Set(removedMainPageSessionKeys)),
4592
+ ...(cleanupErrors.length > 0 ? {
4593
+ cleanupError: cleanupErrors.join('; '),
4594
+ } : {}),
4533
4595
  };
4534
4596
  }
4535
4597
 
@@ -4686,7 +4748,7 @@ class AgentManager extends EventEmitter {
4686
4748
  }
4687
4749
  }
4688
4750
 
4689
- async archiveAgent(agentId) {
4751
+ async archiveAgent(agentId, options = {}) {
4690
4752
  const agent = this.agents.get(agentId);
4691
4753
  if (!agent) {
4692
4754
  return { error: 'Agent not found' };
@@ -4695,13 +4757,26 @@ class AgentManager extends EventEmitter {
4695
4757
  return { error: 'Main Agent cannot be archived' };
4696
4758
  }
4697
4759
 
4760
+ if (options.requireEngineExit !== true) {
4761
+ const removedMainPageSessionKeys = this.removeMainPageProviderSessionsForAgents([agent]);
4762
+ this.ensurePersistentAgentSession(agent, { visibleOnMainPage: false });
4763
+ await this.killAgent(agentId, {
4764
+ reason: options.reason || 'manual-archive',
4765
+ recordHistory: options.recordHistory !== false && !isEphemeralShellAgent(agent),
4766
+ });
4767
+ if (options.scheduleProviderArchive !== false) this.scheduleCodexSessionArchive(agent);
4768
+ return { agentId, archived: true, removed: true, removedMainPageSessionKeys };
4769
+ }
4770
+
4771
+ const killResult = await this.killAgent(agentId, {
4772
+ reason: options.reason || 'manual-archive',
4773
+ recordHistory: options.recordHistory !== false && !isEphemeralShellAgent(agent),
4774
+ requireEngineExit: true,
4775
+ });
4776
+ if (killResult?.error) return killResult;
4698
4777
  const removedMainPageSessionKeys = this.removeMainPageProviderSessionsForAgents([agent]);
4699
4778
  this.ensurePersistentAgentSession(agent, { visibleOnMainPage: false });
4700
- await this.killAgent(agentId, {
4701
- reason: 'manual-archive',
4702
- recordHistory: !isEphemeralShellAgent(agent),
4703
- });
4704
- this.scheduleCodexSessionArchive(agent);
4779
+ if (options.scheduleProviderArchive !== false) this.scheduleCodexSessionArchive(agent);
4705
4780
  return { agentId, archived: true, removed: true, removedMainPageSessionKeys };
4706
4781
  }
4707
4782
 
@@ -4736,15 +4811,42 @@ class AgentManager extends EventEmitter {
4736
4811
 
4737
4812
  async killAgent(agentId, options = {}) {
4738
4813
  const agent = this.agents.get(agentId);
4739
- if (!agent) return;
4814
+ if (!agent) return { agentId, killed: true, missing: true };
4740
4815
 
4816
+ const engine = this.engineBridge.getEngine(agent.engineName);
4817
+ if (options.requireEngineExit === true && !engine) {
4818
+ return { agentId, error: 'Agent runtime is unavailable; process exit cannot be verified' };
4819
+ }
4741
4820
  try {
4742
- const engine = this.engineBridge.getEngine(agent.engineName);
4743
4821
  if (engine) {
4744
4822
  await engine.killSession(agentId);
4745
4823
  }
4746
4824
  } catch (error) {
4747
4825
  console.error('Failed to kill agent:', error);
4826
+ if (options.requireEngineExit === true) {
4827
+ return { agentId, error: error.message || 'Failed to stop Agent runtime' };
4828
+ }
4829
+ }
4830
+
4831
+ if (options.requireEngineExit === true && engine) {
4832
+ const deadline = Date.now() + 3000;
4833
+ let lastState = null;
4834
+ while (Date.now() < deadline) {
4835
+ try {
4836
+ lastState = await engine.getSessionState(agentId);
4837
+ } catch (error) {
4838
+ if (isSessionNotAvailableError(error)) {
4839
+ lastState = null;
4840
+ break;
4841
+ }
4842
+ return { agentId, error: error.message || 'Failed to verify Agent process exit' };
4843
+ }
4844
+ if (!isLiveEngineSessionState(lastState)) break;
4845
+ await new Promise(resolve => setTimeout(resolve, 50));
4846
+ }
4847
+ if (isLiveEngineSessionState(lastState)) {
4848
+ return { agentId, error: 'Agent process did not exit within 3 seconds' };
4849
+ }
4748
4850
  }
4749
4851
 
4750
4852
  if (options.recordHistory !== false && !isEphemeralShellAgent(agent)) {
@@ -4771,6 +4873,7 @@ class AgentManager extends EventEmitter {
4771
4873
  if (options.emitUpdate !== false) {
4772
4874
  this.emit('update');
4773
4875
  }
4876
+ return { agentId, killed: true };
4774
4877
  }
4775
4878
 
4776
4879
  async getAgentSessionText(agentId) {
@@ -0,0 +1,109 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn } = require('child_process');
4
+
5
+ function writeJsonAtomic(filePath, value) {
6
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
7
+ const temporaryPath = `${filePath}.${process.pid}.tmp`;
8
+ fs.writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
9
+ fs.renameSync(temporaryPath, filePath);
10
+ }
11
+
12
+ function appendLog(logPath, message) {
13
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
14
+ fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`);
15
+ }
16
+
17
+ function validatePayload(payload) {
18
+ if (!payload || typeof payload !== 'object') throw new Error('Missing bundle update payload');
19
+ for (const key of ['stateFile', 'logPath', 'releaseDir', 'installer']) {
20
+ if (!path.isAbsolute(String(payload[key] || ''))) throw new Error(`Invalid bundle update ${key}`);
21
+ }
22
+ if (!/^[0-9A-Za-z.+-]+$/.test(String(payload.version || ''))) {
23
+ throw new Error('Invalid bundle update version');
24
+ }
25
+ return payload;
26
+ }
27
+
28
+ function stateFor(payload, phase, extra = {}) {
29
+ return {
30
+ method: payload.method,
31
+ targetMethod: payload.targetMethod,
32
+ phase,
33
+ version: payload.version,
34
+ previousVersion: payload.previousVersion,
35
+ startedAt: payload.startedAt,
36
+ logPath: payload.logPath,
37
+ ...extra,
38
+ };
39
+ }
40
+
41
+ function commandEnvironment() {
42
+ const env = { ...process.env };
43
+ delete env.FARMING_BUNDLE_UPDATE_PAYLOAD;
44
+ delete env.FARMING_RUN_SERVER;
45
+ delete env.FARMING_RUN_NATIVE_PTY_HOST;
46
+ return env;
47
+ }
48
+
49
+ function runInstaller(payload) {
50
+ return new Promise((resolve, reject) => {
51
+ const logFd = fs.openSync(payload.logPath, 'a');
52
+ const child = spawn('bash', [payload.installer, 'install'], {
53
+ cwd: payload.releaseDir,
54
+ env: commandEnvironment(),
55
+ stdio: ['ignore', logFd, logFd],
56
+ });
57
+ child.once('error', error => {
58
+ fs.closeSync(logFd);
59
+ reject(error);
60
+ });
61
+ child.once('exit', (code, signal) => {
62
+ fs.closeSync(logFd);
63
+ if (code === 0) {
64
+ resolve();
65
+ return;
66
+ }
67
+ reject(new Error(`bundle installer exited with ${signal || code}`));
68
+ });
69
+ });
70
+ }
71
+
72
+ async function runBundleUpdate(rawPayload) {
73
+ const payload = validatePayload(rawPayload);
74
+ writeJsonAtomic(payload.stateFile, stateFor(payload, 'installing'));
75
+ await new Promise(resolve => setTimeout(resolve, 1_000));
76
+ try {
77
+ await runInstaller(payload);
78
+ writeJsonAtomic(payload.stateFile, stateFor(payload, 'succeeded', {
79
+ completedAt: new Date().toISOString(),
80
+ }));
81
+ appendLog(payload.logPath, `Farming updated to ${payload.version}`);
82
+ } catch (error) {
83
+ const message = error && error.message ? error.message : String(error);
84
+ writeJsonAtomic(payload.stateFile, stateFor(payload, 'failed', {
85
+ error: message,
86
+ completedAt: new Date().toISOString(),
87
+ }));
88
+ appendLog(payload.logPath, `Update failed: ${message}`);
89
+ }
90
+ }
91
+
92
+ if (require.main === module) {
93
+ let payload;
94
+ try {
95
+ payload = JSON.parse(process.env.FARMING_BUNDLE_UPDATE_PAYLOAD || '');
96
+ } catch {
97
+ console.error('Invalid FARMING_BUNDLE_UPDATE_PAYLOAD');
98
+ process.exit(1);
99
+ }
100
+ runBundleUpdate(payload).catch(error => {
101
+ console.error(error.message || error);
102
+ process.exit(1);
103
+ });
104
+ }
105
+
106
+ module.exports = {
107
+ runBundleUpdate,
108
+ validatePayload,
109
+ };
@@ -222,13 +222,14 @@ class ConfigManager {
222
222
  if (!expanded) continue;
223
223
  const resolved = path.resolve(expanded);
224
224
  if (resolved === path.parse(resolved).root || this.isInternalWorkspace(resolved)) continue;
225
- let canonical;
225
+ let canonical = resolved;
226
226
  try {
227
227
  canonical = fs.realpathSync(resolved);
228
- if (!fs.statSync(canonical).isDirectory()) continue;
229
228
  } catch {
230
- continue;
229
+ // Project membership is durable. A temporarily unavailable path remains
230
+ // until the user explicitly removes it.
231
231
  }
232
+ if (this.isInternalWorkspace(canonical)) continue;
232
233
  if (seen.has(canonical)) continue;
233
234
  seen.add(canonical);
234
235
  result.push(canonical);
@@ -752,6 +753,97 @@ class ConfigManager {
752
753
  };
753
754
  }
754
755
 
756
+ mountProjectWorkspace(workspace) {
757
+ const expanded = this.expandWorkspacePath(workspace);
758
+ const resolved = expanded ? path.resolve(expanded) : '';
759
+ let canonicalWorkspace = '';
760
+ if (resolved) {
761
+ try {
762
+ canonicalWorkspace = fs.realpathSync(resolved);
763
+ if (!fs.statSync(canonicalWorkspace).isDirectory()) canonicalWorkspace = '';
764
+ } catch {
765
+ canonicalWorkspace = '';
766
+ }
767
+ }
768
+ if (
769
+ !canonicalWorkspace
770
+ || canonicalWorkspace === path.parse(canonicalWorkspace).root
771
+ || this.isInternalWorkspace(canonicalWorkspace)
772
+ ) {
773
+ throw new Error('Project workspace is invalid or unavailable');
774
+ }
775
+ const current = this.settings.projectWorkspaces || [];
776
+ if (!current.includes(canonicalWorkspace)) {
777
+ this.commitProjectMembership({
778
+ projectWorkspaces: [canonicalWorkspace, ...current],
779
+ });
780
+ }
781
+ return {
782
+ workspace: canonicalWorkspace,
783
+ projectWorkspaces: [...(this.settings.projectWorkspaces || [])],
784
+ pinnedProjectWorkspaces: [...(this.settings.pinnedProjectWorkspaces || [])],
785
+ };
786
+ }
787
+
788
+ removeProjectWorkspace(workspace) {
789
+ const expanded = this.expandWorkspacePath(workspace);
790
+ if (!expanded) throw new Error('Project workspace is required');
791
+ const resolved = path.resolve(expanded);
792
+ const candidates = new Set([expanded, resolved]);
793
+ try {
794
+ candidates.add(fs.realpathSync(resolved));
795
+ } catch {
796
+ // A deleted worktree can still be removed by its last canonical path.
797
+ }
798
+ const current = this.settings.projectWorkspaces || [];
799
+ const nextProjects = current.filter(entry => !candidates.has(entry));
800
+ const currentPinned = this.settings.pinnedProjectWorkspaces || [];
801
+ const nextPinned = currentPinned.filter(entry => !candidates.has(entry));
802
+ if (nextProjects.length !== current.length || nextPinned.length !== currentPinned.length) {
803
+ this.commitProjectMembership({
804
+ projectWorkspaces: nextProjects,
805
+ pinnedProjectWorkspaces: nextPinned,
806
+ });
807
+ }
808
+ return {
809
+ workspace: current.find(entry => candidates.has(entry)) || resolved,
810
+ projectWorkspaces: [...(this.settings.projectWorkspaces || [])],
811
+ pinnedProjectWorkspaces: [...(this.settings.pinnedProjectWorkspaces || [])],
812
+ };
813
+ }
814
+
815
+ setProjectWorkspacePinned(workspace, pinned) {
816
+ const [canonicalWorkspace] = this.normalizeProjectWorkspaces([workspace]);
817
+ if (!canonicalWorkspace || !(this.settings.projectWorkspaces || []).includes(canonicalWorkspace)) {
818
+ throw new Error('Project does not exist');
819
+ }
820
+ const current = this.settings.pinnedProjectWorkspaces || [];
821
+ const nextPinned = pinned
822
+ ? [...current.filter(entry => entry !== canonicalWorkspace), canonicalWorkspace]
823
+ : current.filter(entry => entry !== canonicalWorkspace);
824
+ if (
825
+ nextPinned.length !== current.length
826
+ || nextPinned.some((entry, index) => entry !== current[index])
827
+ ) {
828
+ this.commitProjectMembership({ pinnedProjectWorkspaces: nextPinned });
829
+ }
830
+ return {
831
+ workspace: canonicalWorkspace,
832
+ projectWorkspaces: [...(this.settings.projectWorkspaces || [])],
833
+ pinnedProjectWorkspaces: [...(this.settings.pinnedProjectWorkspaces || [])],
834
+ };
835
+ }
836
+
837
+ commitProjectMembership(settingsPatch) {
838
+ const previousSettings = this.settings;
839
+ try {
840
+ this.updateSettings(settingsPatch);
841
+ } catch (error) {
842
+ this.settings = previousSettings;
843
+ throw error;
844
+ }
845
+ }
846
+
755
847
  getMainPageSessionKeys() {
756
848
  return this.sessionStore ? this.sessionStore.getMainPageSessionKeys() : [];
757
849
  }
@@ -776,6 +868,10 @@ class ConfigManager {
776
868
  return this.sessionStore ? this.sessionStore.ensureRecordForAgent(agent, patch) : '';
777
869
  }
778
870
 
871
+ getAgentSessionRecordForProviderSessionKey(sessionKey) {
872
+ return this.sessionStore ? this.sessionStore.getRecordForProviderSessionKey(sessionKey) : null;
873
+ }
874
+
779
875
  setProviderSessionDisplayState(sessionKey, patch = {}) {
780
876
  return this.sessionStore ? this.sessionStore.setProviderSessionDisplayState(sessionKey, patch) : '';
781
877
  }
@@ -794,7 +890,17 @@ class ConfigManager {
794
890
 
795
891
  writeSettingsFile() {
796
892
  fs.mkdirSync(this.farmingDir, { recursive: true });
797
- fs.writeFileSync(this.settingsFile, JSON.stringify(this.settings, null, 2));
893
+ const tempFile = `${this.settingsFile}.${process.pid}.${Date.now()}.tmp`;
894
+ try {
895
+ fs.writeFileSync(tempFile, JSON.stringify(this.settings, null, 2));
896
+ fs.renameSync(tempFile, this.settingsFile);
897
+ } finally {
898
+ try {
899
+ fs.unlinkSync(tempFile);
900
+ } catch {
901
+ // A successful rename already removed the temporary path.
902
+ }
903
+ }
798
904
  }
799
905
 
800
906
  appendTaskHistory(entry) {
@@ -6,6 +6,24 @@ const storageLayout = require('./storage-layout');
6
6
 
7
7
  const SESSION_ID_PREFIX = 'fsess';
8
8
  const MAX_MAIN_PAGE_SESSION_KEYS = 50;
9
+ const PRODUCT_STATE_FIELDS = [
10
+ 'projectWorkspace',
11
+ 'task',
12
+ 'workflowTemplate',
13
+ 'pinned',
14
+ 'projectOrder',
15
+ 'pinnedOrder',
16
+ 'attentionSeq',
17
+ 'readAttentionSeq',
18
+ 'attentionUpdatedAt',
19
+ 'readAttentionAt',
20
+ 'attentionReason',
21
+ 'attentionOutputEpoch',
22
+ 'attentionOutputSeq',
23
+ 'readOutputEpoch',
24
+ 'readOutputSeq',
25
+ 'customTitle',
26
+ ];
9
27
 
10
28
  function now() {
11
29
  return Date.now();
@@ -184,10 +202,15 @@ class FarmingSessionStore {
184
202
  attentionUpdatedAt: typeof agent.attentionUpdatedAt === 'number' ? agent.attentionUpdatedAt : null,
185
203
  readAttentionAt: typeof agent.readAttentionAt === 'number' ? agent.readAttentionAt : null,
186
204
  attentionReason: typeof agent.attentionReason === 'string' ? agent.attentionReason : '',
205
+ attentionOutputEpoch: typeof agent.attentionOutputEpoch === 'string' ? agent.attentionOutputEpoch : '',
187
206
  attentionOutputSeq: typeof agent.attentionOutputSeq === 'number' ? agent.attentionOutputSeq : null,
207
+ readOutputEpoch: typeof agent.readOutputEpoch === 'string' ? agent.readOutputEpoch : '',
208
+ readOutputSeq: typeof agent.readOutputSeq === 'number' ? agent.readOutputSeq : null,
188
209
  archived: agent.archived === true,
189
210
  archivedAt: typeof agent.archivedAt === 'number' ? agent.archivedAt : null,
190
- customTitle: typeof agent.customTitle === 'string' ? agent.customTitle : '',
211
+ ...(typeof agent.customTitle === 'string' && agent.customTitle
212
+ ? { customTitle: agent.customTitle }
213
+ : {}),
191
214
  title: typeof agent.customTitle === 'string' && agent.customTitle
192
215
  ? agent.customTitle
193
216
  : (typeof agent.providerSessionTitle === 'string' && agent.providerSessionTitle
@@ -222,6 +245,9 @@ class FarmingSessionStore {
222
245
  providerSessionTemporary: false,
223
246
  updatedAt: now(),
224
247
  };
248
+ if (typeof record.customTitle === 'string' && record.customTitle) {
249
+ record.title = record.customTitle;
250
+ }
225
251
  index.providerSessionRecords[sessionKey] = id;
226
252
  this.writeRecord(record);
227
253
  this.writeIndex();
@@ -231,10 +257,38 @@ class FarmingSessionStore {
231
257
  ensureRecordForAgent(agent, patch = {}) {
232
258
  const providerSessionKey = this.providerSessionKeyForAgent(agent);
233
259
  if (providerSessionKey) {
260
+ const previousId = safeSessionFileName(agent?.persistentSessionId)
261
+ ? agent.persistentSessionId
262
+ : '';
263
+ const previous = previousId ? this.readRecord(previousId) : null;
264
+ const canonical = this.getRecordForProviderSessionKey(providerSessionKey);
265
+ const agentPatch = this.recordPatchFromAgent(agent);
266
+ if (canonical && previousId && canonical.id !== previousId) {
267
+ PRODUCT_STATE_FIELDS.forEach(field => {
268
+ if (Object.prototype.hasOwnProperty.call(canonical, field)) {
269
+ agentPatch[field] = canonical[field];
270
+ } else if (previous && Object.prototype.hasOwnProperty.call(previous, field)) {
271
+ agentPatch[field] = previous[field];
272
+ }
273
+ });
274
+ }
234
275
  const id = this.ensureRecordForProviderSessionKey(providerSessionKey, {
235
- ...this.recordPatchFromAgent(agent),
276
+ ...agentPatch,
236
277
  ...patch,
237
278
  }, agent?.persistentSessionId || '');
279
+ if (previousId && previousId !== id) {
280
+ if (previous && (previous.providerSessionTemporary === true || !previous.providerSessionKey)) {
281
+ this.writeRecord({
282
+ ...previous,
283
+ runtimeAgentId: '',
284
+ visibleOnMainPage: false,
285
+ archived: true,
286
+ archivedAt: now(),
287
+ mergedInto: id,
288
+ updatedAt: now(),
289
+ });
290
+ }
291
+ }
238
292
  return id;
239
293
  }
240
294
 
@@ -345,6 +399,12 @@ class FarmingSessionStore {
345
399
  return this.ensureIndex().mainPageSessionKeys.slice();
346
400
  }
347
401
 
402
+ getRecordForProviderSessionKey(sessionKey) {
403
+ const index = this.ensureIndex();
404
+ const id = index.providerSessionRecords[sessionKey];
405
+ return safeSessionFileName(id) ? this.readRecord(id) : null;
406
+ }
407
+
348
408
  listAgentRecords() {
349
409
  this.ensureIndex();
350
410
  let names = [];