@goodandready/dsh-cron 0.3.19 → 0.3.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -96,8 +96,9 @@ graph TD
96
96
  - **Automated Synthesis**: Aggregates worker outputs into a structured Markdown digest or delegates synthesis to a designated synthesizer model.
97
97
  - **Swarm Metrics & History**: Run history records total, succeeded, failed counts, and duration alongside detailed per-worker execution logs.
98
98
 
99
- ### 🔍 Git Diff & Interactive Patch Review (#351)
100
- - **Automatic Diff Capture**: Tasks running in Git worktrees capture modified files, stat summary, and unified diffs (up to 256 KiB) automatically.
99
+ ### 🔍 Git Diff & Interactive Patch Review (#351, #379)
100
+ - **Scoped Diff Capture**: Tasks running in Git worktrees capture modified files, stat summary, and unified diffs (up to 256 KiB) automatically. Non-worktree tasks ignore repository diffs by default, avoiding storage bloat, but can opt in explicitly via `captureGitDiff: true`.
101
+ - **Lightweight Archive Storage**: Historical run archives automatically strip heavy raw diff payloads into concise excerpts with byte-budget caps (max 15MB) and automatic cleanup of orphaned `.tmp.*` files.
101
102
  - **1-Click Discard**: Revert uncommitted working directory changes safely with a single click (`git reset --hard HEAD && git clean -fd`).
102
103
  - **1-Click Apply**: Re-apply previously captured diffs directly to the repository (`git apply --whitespace=fix`).
103
104
  - **Interactive Run Preview**: Color-coded diff inspector embedded directly into the execution history drawer.
@@ -215,7 +216,7 @@ Every task picks its own runtime; non-LLM runtimes need no model and consume no
215
216
 
216
217
  ### 8. Session Integration & Permissions
217
218
  * **Real Assistant Output & Terminal Status Extraction** — Isolates current turn session events, extracts the actual assistant message text (excluding previous turn history in persistent sessions), and validates terminal turn status (`turn/end` errors or interruptions) to guarantee accurate reporting, chaining, and structured action execution.
218
- * **Per-task permission presets** — `default`, `read-only`, `workspace-write`, or `full` are applied to the task's agent session before the prompt runs.
219
+ * **Per-task permission presets & unattended approval** — `default`, `read-only`, `workspace-write`, or `danger-full-access` (aliased as `full`) are mapped directly to host `SandboxMode` and applied to the task's agent session before the prompt runs. For unattended background runs, approval policy is pinned to `never` via host `approval` service to eliminate interactive approval stalls.
219
220
  * **Session auto-archive** — isolated cron sessions are archived after each run (best-effort) so they do not clutter the chat list.
220
221
  * **History → session navigation** — every LLM run records its session; open it straight from the run history entry.
221
222
 
@@ -169,7 +169,7 @@ export function buildTaskRecord(store, body, taskType, parsed) {
169
169
  misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
170
170
  maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
171
171
  retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
172
- permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
172
+ permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current && !EXTERNAL_RUNTIMES.includes(taskType) ? (current.permissionPreset || 'default') : 'default'),
173
173
  validators: Array.isArray(body.validators) ? sanitizeValidators(body.validators) : (current ? current.validators : undefined),
174
174
  ...mergeExecutionFields(current, body, parsed),
175
175
  };
@@ -194,6 +194,7 @@ export function mergeExecutionFields(current, body, parsed) {
194
194
  workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
195
195
  worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
196
196
  keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
197
+ captureGitDiff: body.captureGitDiff !== undefined ? Boolean(body.captureGitDiff) : (body.captureDiff !== undefined ? Boolean(body.captureDiff) : (current ? current.captureGitDiff : undefined)),
197
198
  httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
198
199
  httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
199
200
  httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
package/lib/client.css CHANGED
@@ -13,18 +13,18 @@
13
13
  [data-pane=conversation], [class*=centerCol] {
14
14
  position: relative !important;
15
15
  }
16
- [${VIEW_ATTR}] {
16
+ [data-dsh-cron-view] {
17
17
  z-index: 60;
18
18
  background: var(--dsw-alias-bg-base);
19
19
  display: none;
20
20
  position: absolute;
21
21
  inset: 0;
22
22
  }
23
- html[${ACTIVE_ATTR}] [${VIEW_ATTR}] {
23
+ html[data-dsh-cron-active] [data-dsh-cron-view] {
24
24
  display: block !important;
25
25
  }
26
- html[${ACTIVE_ATTR}] [data-pane=conversation] > *:not([${VIEW_ATTR}]),
27
- html[${ACTIVE_ATTR}] [class*=centerCol] > *:not([${VIEW_ATTR}]) {
26
+ html[data-dsh-cron-active] [data-pane=conversation] > *:not([data-dsh-cron-view]),
27
+ html[data-dsh-cron-active] [class*=centerCol] > *:not([data-dsh-cron-view]) {
28
28
  display: none !important;
29
29
  }
30
30
 
package/lib/client.js CHANGED
@@ -470,8 +470,12 @@ const listeners = new Set();
470
470
  return {
471
471
  isOpen: () => open,
472
472
  set: (v) => {
473
- if (open === v) return;
474
- open = !!v;
473
+ const next = !!v;
474
+ if (open === next) {
475
+ for (const fn of listeners) fn(open);
476
+ return;
477
+ }
478
+ open = next;
475
479
  for (const fn of listeners) fn(open);
476
480
  },
477
481
  toggle: () => {
@@ -1328,6 +1332,12 @@ style: { padding: '6px 16px', fontSize: '12px' }
1328
1332
  function CronScreen(props) {
1329
1333
  const { ctx, toggle, onClose } = props;
1330
1334
  const t = props.t || translate;
1335
+ const [isOpen, setIsOpen] = React.useState(toggle && typeof toggle.isOpen === 'function' ? toggle.isOpen() : true);
1336
+ React.useEffect(() => {
1337
+ if (!toggle || typeof toggle.subscribe !== 'function') return;
1338
+ return toggle.subscribe((val) => setIsOpen(val));
1339
+ }, [toggle]);
1340
+ if (!isOpen) return null;
1331
1341
  const [tab, setTab] = React.useState('all');
1332
1342
  const [query, setQuery] = React.useState('');
1333
1343
  const [filterType, setFilterType] = React.useState('');
@@ -1431,6 +1441,7 @@ const [formCwd, setFormCwd] = React.useState('');
1431
1441
  const [formWorkspaceId, setFormWorkspaceId] = React.useState('');
1432
1442
  const [formWorktree, setFormWorktree] = React.useState(false);
1433
1443
  const [formKeepWorktree, setFormKeepWorktree] = React.useState(false);
1444
+ const [formCaptureGitDiff, setFormCaptureGitDiff] = React.useState(false);
1434
1445
  const [formHttpMethod, setFormHttpMethod] = React.useState('GET');
1435
1446
  const [formHttpUrl, setFormHttpUrl] = React.useState('');
1436
1447
  const [formHttpHeaders, setFormHttpHeaders] = React.useState('');
@@ -1486,6 +1497,7 @@ cwd: String(formCwd || '').trim(),
1486
1497
  workspaceId: String(formWorkspaceId || '').trim(),
1487
1498
  worktree: Boolean(formWorktree),
1488
1499
  keepWorktree: Boolean(formKeepWorktree),
1500
+ captureGitDiff: Boolean(formCaptureGitDiff),
1489
1501
  httpMethod: String(formHttpMethod || 'GET'),
1490
1502
  httpUrl: String(formHttpUrl || '').trim(),
1491
1503
  httpHttpHeaders: String(formHttpHeaders || '').trim(),
@@ -2120,6 +2132,7 @@ setFormCwd('');
2120
2132
  setFormWorkspaceId('');
2121
2133
  setFormWorktree(false);
2122
2134
  setFormKeepWorktree(false);
2135
+ setFormCaptureGitDiff(false);
2123
2136
  setFormHttpMethod('GET');
2124
2137
  setFormHttpUrl('');
2125
2138
  setFormHttpHeaders('');
@@ -2207,6 +2220,7 @@ cwd: '',
2207
2220
  workspaceId: '',
2208
2221
  worktree: false,
2209
2222
  keepWorktree: false,
2223
+ captureGitDiff: false,
2210
2224
  httpMethod: 'GET',
2211
2225
  httpUrl: '',
2212
2226
  httpHttpHeaders: '',
@@ -2332,7 +2346,7 @@ setSchedulePreviewRuns(null);
2332
2346
  setFormTimezone(task.timezone || '');
2333
2347
  setFormMisfirePolicy(task.misfirePolicy || 'skip');
2334
2348
  setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
2335
- setFormPermissionPreset(task.permissionPreset || 'default');
2349
+ setFormPermissionPreset(task.permissionPreset === 'full' ? 'danger-full-access' : (task.permissionPreset || 'default'));
2336
2350
  setFormAgentPreset(task.agentPreset || '');
2337
2351
  setFormSessionTitle(task.sessionTitle || '');
2338
2352
  setFormTargetSessionId(task.targetSessionId || '');
@@ -2342,6 +2356,7 @@ setFormCwd(task.cwd || '');
2342
2356
  setFormWorkspaceId(task.workspaceId || '');
2343
2357
  setFormWorktree(Boolean(task.worktree));
2344
2358
  setFormKeepWorktree(Boolean(task.keepWorktree));
2359
+ setFormCaptureGitDiff(task.captureGitDiff !== undefined ? Boolean(task.captureGitDiff) : Boolean(task.worktree));
2345
2360
  setFormHttpMethod(task.httpMethod || 'GET');
2346
2361
  setFormHttpUrl(task.httpUrl || '');
2347
2362
  setFormHttpHeaders(typeof task.httpHeaders === 'string' ? task.httpHeaders : (task.httpHeaders ? JSON.stringify(task.httpHeaders) : ''));
@@ -2416,6 +2431,7 @@ cwd: formCwd.trim(),
2416
2431
  workspaceId: formWorkspaceId.trim(),
2417
2432
  worktree: isAgentType ? Boolean(formWorktree) : false,
2418
2433
  keepWorktree: isAgentType ? Boolean(formKeepWorktree) : false,
2434
+ captureGitDiff: Boolean(formCaptureGitDiff),
2419
2435
  httpMethod: formHttpMethod,
2420
2436
  httpUrl: formHttpUrl.trim(),
2421
2437
  httpHeaders: formHttpHeaders.trim() || undefined,
@@ -3413,7 +3429,7 @@ onChange: (e) => setFormPermissionPreset(e.target.value)
3413
3429
  _h('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
3414
3430
  _h('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
3415
3431
  _h('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
3416
- _h('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
3432
+ _h('option', { value: 'danger-full-access' }, T_KEY(t, 'form.permissionFull'))
3417
3433
  )
3418
3434
  )
3419
3435
  )
@@ -3673,6 +3689,14 @@ checked: formKeepWorktree,
3673
3689
  onChange: (e) => setFormKeepWorktree(e.target.checked)
3674
3690
  }),
3675
3691
  _h('span', null, T_KEY(t, 'form.keepWorktreeLabel'))
3692
+ ),
3693
+ _h('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: formWorktree ? '24px' : '0px', marginTop: '6px' } },
3694
+ _h('input', {
3695
+ type: 'checkbox',
3696
+ checked: formCaptureGitDiff,
3697
+ onChange: (e) => setFormCaptureGitDiff(e.target.checked)
3698
+ }),
3699
+ _h('span', null, T_KEY(t, 'form.captureGitDiffLabel'))
3676
3700
  )
3677
3701
  ),
3678
3702
  formType !== 'http' && _h('div', { className: 'dsh-cron-form-group' },
@@ -4914,7 +4938,21 @@ function mountCronScreen(ctx, toggle) {
4914
4938
  if (typeof document === 'undefined') return () => {};
4915
4939
  let root;
4916
4940
  let container;
4941
+ const teardownPanel = () => {
4942
+ if (root !== undefined) {
4943
+ try { root.unmount(); } catch (e) { /* ignore */ }
4944
+ root = undefined;
4945
+ }
4946
+ if (container !== undefined) {
4947
+ try { container.remove(); } catch (e) { /* ignore */ }
4948
+ container = undefined;
4949
+ }
4950
+ };
4917
4951
  const ensure = () => {
4952
+ if (!toggle.isOpen()) {
4953
+ teardownPanel();
4954
+ return;
4955
+ }
4918
4956
  if (container !== undefined && container.isConnected) return;
4919
4957
  const column = document.querySelector(COLUMN_SELECTOR);
4920
4958
  if (column === null) return;
@@ -4934,8 +4972,10 @@ if (toggle.isOpen()) {
4934
4972
  ensureStyles();
4935
4973
  document.documentElement.setAttribute(ACTIVE_ATTR, '');
4936
4974
  document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL }));
4975
+ ensure();
4937
4976
  } else {
4938
4977
  document.documentElement.removeAttribute(ACTIVE_ATTR);
4978
+ teardownPanel();
4939
4979
  }
4940
4980
  };
4941
4981
  const onOtherActivate = (event) => {
@@ -4959,10 +4999,7 @@ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate);
4959
4999
  document.removeEventListener('click', onSidebarClick, true);
4960
5000
  unsubscribe();
4961
5001
  document.documentElement.removeAttribute(ACTIVE_ATTR);
4962
- if (root !== undefined) root.unmount();
4963
- root = undefined;
4964
- if (container !== undefined) container.remove();
4965
- container = undefined;
5002
+ teardownPanel();
4966
5003
  };
4967
5004
  }
4968
5005
  function registerIntoMount(ctx, mountName, entry, component) {
package/lib/cron-tool.js CHANGED
@@ -149,9 +149,13 @@ export const cronToolParameters = {
149
149
  },
150
150
  permissionPreset: {
151
151
  type: 'string',
152
- enum: ['default', 'read-only', 'workspace-write', 'full'],
152
+ enum: ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'],
153
153
  description: 'Permission preset applied to the task session (only used for agent session tasks: llm, skill, workflow)',
154
154
  },
155
+ captureGitDiff: {
156
+ type: 'boolean',
157
+ description: 'Whether to capture git diff on run completion. Defaults to true for worktree tasks and false for non-worktree tasks.',
158
+ },
155
159
  sessionTitle: {
156
160
  type: 'string',
157
161
  description: 'Custom session title template for isolated agent runs (supports {{date}} placeholder, e.g. "{{date}} [Daily] Report")',
package/lib/locales.json CHANGED
@@ -179,6 +179,7 @@
179
179
  "form.permissionDefault": "默认 (继承主配置)",
180
180
  "form.permissionRestricted": "受限模式 (只读)",
181
181
  "form.permissionFull": "完全访问权限",
182
+ "form.captureGitDiffLabel": "捕获 Git Diff (任务结束时记录代码变更)",
182
183
  "form.envLabel": "环境变量 (KEY=VALUE, 每行一个)",
183
184
  "form.envPlaceholder": "FOO=bar\\nAPI_KEY=secret",
184
185
  "form.cwdLabel": "工作目录 (绝对路径)",
package/lib/runner.js CHANGED
@@ -644,16 +644,37 @@ export class SessionRunner {
644
644
  }
645
645
  }
646
646
 
647
- /** Per-job permission preset (#32): read-only | workspace-write | full. */
647
+ /** Per-job permission preset (#32, #380, GH #18): read-only | workspace-write | danger-full-access (or full alias). */
648
648
  _applyPermissionPreset(task, handle) {
649
- if (!task.permissionPreset || task.permissionPreset === 'default') return;
649
+ if (!task.permissionPreset || task.permissionPreset === 'default') {
650
+ this._pinApprovalPolicy(handle);
651
+ return;
652
+ }
653
+ const targetPreset = task.permissionPreset === 'full' ? 'danger-full-access' : task.permissionPreset;
650
654
  try {
651
- const presets = this.ctx.get && this.ctx.get('permissionPresets');
655
+ const presets = this.ctx.get ? this.ctx.get('permissionPresets') : this.ctx.permissionPresets;
652
656
  if (presets && typeof presets.set === 'function') {
653
- presets.set(handle.agent.session, task.permissionPreset);
657
+ presets.set(handle.agent.session, targetPreset);
654
658
  }
655
659
  } catch (ppErr) {
656
- logger.warn('[dsh-cron] permission preset not applied:', ppErr.message);
660
+ logger.warn(`[dsh-cron] permission preset "${task.permissionPreset}" (target: "${targetPreset}") not applied to task ${task.id || ''}:`, ppErr.message);
661
+ }
662
+ this._pinApprovalPolicy(handle);
663
+ }
664
+
665
+ _pinApprovalPolicy(handle) {
666
+ try {
667
+ const approval = this.ctx.get ? this.ctx.get('approval') : this.ctx.approval;
668
+ if (approval && typeof approval.setPolicy === 'function') {
669
+ if (handle?.agent) {
670
+ bestEffort('approval-set-policy-agent', () => approval.setPolicy(handle.agent, 'never'), logger);
671
+ }
672
+ if (handle?.agent?.session) {
673
+ bestEffort('approval-set-policy-session', () => approval.setPolicy(handle.agent.session, 'never'), logger);
674
+ }
675
+ }
676
+ } catch (appErr) {
677
+ logger.warn('[dsh-cron] approval setPolicy not applied:', appErr.message);
657
678
  }
658
679
  }
659
680
 
@@ -142,16 +142,20 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
142
142
  runInfo.swarmStats = outcome.swarmStats;
143
143
  }
144
144
 
145
- // Capture git diff if task runs in a worktree or custom git cwd (#351)
146
- const diffDir = task.worktree ? (runInfo.worktreePath || task.cwd) : task.cwd;
145
+ // Capture git diff if task runs in a worktree or explicitly opted in (#351, #379, GH #17)
146
+ const explicitCapture = (typeof task.captureGitDiff === 'boolean')
147
+ ? task.captureGitDiff
148
+ : ((typeof task.captureDiff === 'boolean') ? task.captureDiff : null);
149
+ const shouldCaptureDiff = explicitCapture !== null ? explicitCapture : Boolean(task.worktree);
150
+ const diffDir = shouldCaptureDiff ? (task.worktree ? (runInfo.worktreePath || task.cwd) : task.cwd) : null;
147
151
  if (diffDir) {
148
152
  try {
149
153
  const diffResult = await captureGitDiff(diffDir);
150
154
  if (diffResult && diffResult.hasChanges) {
151
155
  runInfo.gitDiff = diffResult;
152
156
  }
153
- } catch {
154
- // Non-fatal if diff capture fails
157
+ } catch (diffErr) {
158
+ bestEffort('log-diff-error', () => scheduler?.logger?.debug?.(`[dsh-cron] git diff capture failed: ${diffErr?.message}`));
155
159
  }
156
160
  }
157
161
 
package/lib/store.js CHANGED
@@ -60,6 +60,7 @@ export class TaskStore {
60
60
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
61
61
  }
62
62
  bestEffort('chmod-dir', () => fs.chmodSync(dir, 0o700));
63
+ this._cleanOrphanedTmpFiles(dir);
63
64
  const bakPath = `${this.filePath}.bak`;
64
65
 
65
66
  const loadData = (raw) => {
@@ -823,6 +824,17 @@ export class TaskStore {
823
824
  },
824
825
  ...prevRuns,
825
826
  ];
827
+ // Truncate raw diff in older active history entries to bound memory and JSON size (#379, GH #17)
828
+ for (let i = 10; i < runs.length; i++) {
829
+ const r = runs[i];
830
+ if (r && r.gitDiff && r.gitDiff.diff && r.gitDiff.diff.length > 2000) {
831
+ r.gitDiff = {
832
+ ...r.gitDiff,
833
+ diff: r.gitDiff.diff.slice(0, 2000) + '\n... [diff truncated in history]',
834
+ truncated: true,
835
+ };
836
+ }
837
+ }
826
838
  // Keep up to 100 runs in active store; archive older runs to tasks-history-archive.json (#134)
827
839
  if (runs.length > 100) {
828
840
  const overflow = runs.splice(100);
@@ -833,7 +845,32 @@ export class TaskStore {
833
845
  }
834
846
 
835
847
  /**
836
- * Append rotated overflow history entries into tasks-history-archive.json (#134).
848
+ * Remove orphaned temporary write files older than 10 minutes (#379, GH #17).
849
+ */
850
+ _cleanOrphanedTmpFiles(dir) {
851
+ try {
852
+ if (!fs.existsSync(dir)) return;
853
+ const entries = fs.readdirSync(dir);
854
+ const now = Date.now();
855
+ const tenMinutesMs = 10 * 60 * 1000;
856
+ for (const entry of entries) {
857
+ if (entry.includes('.tmp.')) {
858
+ const fullPath = path.join(dir, entry);
859
+ bestEffort('clean-tmp-file', () => {
860
+ const stat = fs.statSync(fullPath);
861
+ if (stat.isFile() && (now - stat.mtimeMs > tenMinutesMs)) {
862
+ fs.unlinkSync(fullPath);
863
+ }
864
+ });
865
+ }
866
+ }
867
+ } catch (dirErr) {
868
+ bestEffort('log-clean-tmp-dir-error', () => logger.debug?.(`[dsh-cron] clean orphaned tmp dir error: ${dirErr?.message}`));
869
+ }
870
+ }
871
+
872
+ /**
873
+ * Append rotated overflow history entries into tasks-history-archive.json (#134, #379, GH #17).
837
874
  */
838
875
  _archiveHistoryRuns(id, overflowRuns) {
839
876
  if (!Array.isArray(overflowRuns) || overflowRuns.length === 0) return;
@@ -845,14 +882,43 @@ export class TaskStore {
845
882
  archive = bestEffort('parse-history-archive', () => JSON.parse(fs.readFileSync(archivePath, 'utf-8'))) || {};
846
883
  }
847
884
  if (!Array.isArray(archive[id])) archive[id] = [];
848
- archive[id].push(...overflowRuns);
885
+
886
+ // Strip full raw diff payload from archived overflow runs to prevent runaway JSON growth (#379, GH #17)
887
+ const sanitizedOverflow = overflowRuns.map((r) => {
888
+ if (!r || !r.gitDiff) return r;
889
+ const { diff, ...gitDiffMeta } = r.gitDiff;
890
+ return {
891
+ ...r,
892
+ gitDiff: {
893
+ ...gitDiffMeta,
894
+ diffExcerpt: diff ? diff.slice(0, 500) : '',
895
+ hasChanges: Boolean(gitDiffMeta.hasChanges),
896
+ archived: true,
897
+ },
898
+ };
899
+ });
900
+
901
+ archive[id].push(...sanitizedOverflow);
849
902
  if (archive[id].length > 1000) {
850
903
  archive[id] = archive[id].slice(-1000);
851
904
  }
905
+
906
+ // Enforce total archive size budget (max 15MB) across tasks (#379, GH #17)
907
+ const MAX_ARCHIVE_BYTES = 15 * 1024 * 1024;
908
+ let serialized = JSON.stringify(archive);
909
+ if (serialized.length > MAX_ARCHIVE_BYTES) {
910
+ for (const taskId of Object.keys(archive)) {
911
+ if (Array.isArray(archive[taskId]) && archive[taskId].length > 100) {
912
+ archive[taskId] = archive[taskId].slice(-100);
913
+ }
914
+ }
915
+ serialized = JSON.stringify(archive);
916
+ }
917
+
852
918
  // Write atomically with tmp file using compact JSON (avoiding mega-lines and null, 2 serialization bloat)
853
919
  const rand = Math.random().toString(36).slice(2, 8);
854
920
  const tmp = `${archivePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
855
- fs.writeFileSync(tmp, JSON.stringify(archive), { encoding: 'utf-8', mode: 0o600 });
921
+ fs.writeFileSync(tmp, serialized, { encoding: 'utf-8', mode: 0o600 });
856
922
  bestEffort('chmod-tmp', () => fs.chmodSync(tmp, 0o600));
857
923
  fs.renameSync(tmp, archivePath);
858
924
  bestEffort('chmod-archive', () => fs.chmodSync(archivePath, 0o600));
@@ -85,7 +85,8 @@ export function executeCreateTask(store, scheduler, args) {
85
85
  misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
86
86
  maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
87
87
  retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
88
- permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
88
+ permissionPreset: !EXTERNAL_RUNTIMES.includes(taskType) && ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
89
+ captureGitDiff: typeof args.captureGitDiff === 'boolean' ? args.captureGitDiff : (typeof args.captureDiff === 'boolean' ? args.captureDiff : undefined),
89
90
  kanbanMode: args.kanbanMode || 'none',
90
91
  channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
91
92
  template: args.template ? String(args.template) : '',
package/lib/task-patch.js CHANGED
@@ -156,6 +156,16 @@ export function buildTaskPatch(current, body) {
156
156
  if (patch.tokenLimit !== undefined) {
157
157
  patch.tokenLimit = patch.tokenLimit === null ? null : (Number(patch.tokenLimit) > 0 ? Number(patch.tokenLimit) : null);
158
158
  }
159
+ if (patch.permissionPreset !== undefined) {
160
+ patch.permissionPreset = ['default', 'read-only', 'workspace-write', 'danger-full-access', 'full'].includes(patch.permissionPreset)
161
+ ? patch.permissionPreset
162
+ : 'default';
163
+ }
164
+ if (patch.captureGitDiff !== undefined) {
165
+ patch.captureGitDiff = Boolean(patch.captureGitDiff);
166
+ } else if (patch.captureDiff !== undefined) {
167
+ patch.captureGitDiff = Boolean(patch.captureDiff);
168
+ }
159
169
  const effectiveType = patch.type !== undefined ? normalizeTaskType(patch.type) : current.type;
160
170
  if (EXTERNAL_RUNTIMES.includes(effectiveType) && patch.permissionPreset !== undefined) {
161
171
  patch.permissionPreset = 'default';
@@ -44,6 +44,7 @@ export const PATCHABLE_TASK_FIELDS = [
44
44
  'workspaceId',
45
45
  'worktree',
46
46
  'keepWorktree',
47
+ 'captureGitDiff',
47
48
  'httpMethod',
48
49
  'httpUrl',
49
50
  'httpHeaders',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.19",
3
+ "version": "0.3.21",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",