@goodandready/dsh-kanban 0.2.33 → 0.2.34

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/lib/config.js CHANGED
@@ -81,6 +81,16 @@ export const CONFIG_DEFAULTS = {
81
81
  verificationTimeoutMs: 120000,
82
82
  costMeterEnabled: true,
83
83
  taskCostLimit: 0,
84
+ remoteMode: false,
85
+ remoteHost: '192.168.1.111',
86
+ remotePort: 22,
87
+ remoteUser: 'migrate',
88
+ remoteAuthType: 'key',
89
+ remoteKeyPath: '',
90
+ remoteSudoUser: 'vadim',
91
+ remoteRoot: '/mnt/external/Project/DEV',
92
+ remoteServerUrl: 'http://192.168.1.111:3080',
93
+ remoteServerToken: '',
84
94
  }
85
95
 
86
96
  /** Field descriptions reused in schema and settings card. */
@@ -121,6 +131,16 @@ export const CONFIG_HINTS = {
121
131
  verificationTimeoutMs: 'Timeout in milliseconds for verification gate execution (default: 120000).',
122
132
  costMeterEnabled: 'Enable token and cost telemetry integration via @goodandready/dsh-cost-meter.',
123
133
  taskCostLimit: 'Cost threshold per task in USD; 0 disables threshold warnings.',
134
+ remoteMode: 'Enable remote execution on server 111 for tasks, worktrees, and git operations.',
135
+ remoteHost: 'Remote server hostname or IP address (e.g. 192.168.1.111).',
136
+ remotePort: 'Remote SSH port (default: 22).',
137
+ remoteUser: 'SSH username on remote server (e.g. migrate or vadim).',
138
+ remoteAuthType: 'SSH authentication mechanism: key, agent, or password.',
139
+ remoteKeyPath: 'Path to SSH private key file on local PC (e.g. C:/Users/.../.ssh/key).',
140
+ remoteSudoUser: 'Linux user to run commands as via sudo -u (e.g. vadim).',
141
+ remoteRoot: 'Base directory on remote server containing project repositories (/mnt/external/Project/DEV).',
142
+ remoteServerUrl: 'DSH server URL on 111 (http://192.168.1.111:3080) for unified board state and sessions.',
143
+ remoteServerToken: 'Authentication token for remote DSH server if required.',
124
144
  }
125
145
 
126
146
  /** Setting key for column WIP limit; not all columns have limits. */
@@ -137,7 +157,11 @@ const WIP_FIELD = {
137
157
  */
138
158
  export function rootOf(config, cwdOf = () => process.cwd()) {
139
159
  const named = String(config?.defaultProjectRoot ?? '').trim()
140
- return named !== '' ? { path: named, set: true } : { path: cwdOf(), set: false }
160
+ if (named !== '') return { path: named, set: true }
161
+ if (config?.remoteMode && config?.remoteRoot) {
162
+ return { path: String(config.remoteRoot).trim(), set: true }
163
+ }
164
+ return { path: cwdOf(), set: false }
141
165
  }
142
166
 
143
167
  /**
package/lib/index.js CHANGED
@@ -54,6 +54,8 @@ import {
54
54
  handleTaskNudge,
55
55
  } from './routes.js'
56
56
  import { isCostMeterAvailable } from './cost-telemetry.js'
57
+ import { createRemoteGitRunner, testRemoteConnection, isRemoteMode } from './remote-runner.js'
58
+ import { shouldRelay, relayRequest, testRemoteServer } from './remote-relay.js'
57
59
  import { listImportable, importIssue, refreshTask, searchRepos, createProjectTask } from './import.js'
58
60
  import {
59
61
  runTask, resolveModel, buildStartMessage,
@@ -213,6 +215,7 @@ function urlOf(req) {
213
215
  export function apply(ctx, config) {
214
216
  let effective = withDefaults(config)
215
217
  const getConfig = () => effective
218
+ const getGitRunner = () => createRemoteGitRunner(getConfig(), defaultGitRunner)
216
219
 
217
220
  // Settings control live subscriptions: sync intervals and agent tools.
218
221
  // Dynamic watcher re-evaluates configurations on change without requiring restart.
@@ -887,24 +890,71 @@ export function apply(ctx, config) {
887
890
  ctx.effect(() => ctx.webServer.register({
888
891
  kind: 'exact',
889
892
  path: '/dsh-kanban/board',
890
- handler: (req, res) => {
893
+ handler: async (req, res) => {
891
894
  if (req.method !== 'GET') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
892
895
  if (rejectUntrusted(req, res)) return
896
+ const cfg = getConfig()
897
+ if (shouldRelay(cfg)) {
898
+ const url = urlOf(req)
899
+ const relayRes = await relayRequest({
900
+ config: cfg,
901
+ urlPath: '/dsh-kanban/board' + (url.search || ''),
902
+ method: 'GET',
903
+ headers: req.headers,
904
+ })
905
+ writeJson(res, relayRes.status, relayRes.body)
906
+ return
907
+ }
893
908
  const url = urlOf(req)
894
909
  reply(res, buildBoard({
895
910
  store,
896
- config: getConfig(),
911
+ config: cfg,
897
912
  board: url.searchParams.get('board') || 'main',
898
913
  repo: url.searchParams.get('repo') || undefined,
899
914
  liveOf,
900
915
  sync: syncState.snapshot(),
901
916
  // Resolved project root passed with board payload for session launch modal.
902
- projectRoot: rootOf(getConfig()),
917
+ projectRoot: rootOf(cfg),
903
918
  costMeterAvailable: isCostMeterAvailable(ctx),
904
919
  }))
905
920
  },
906
921
  }), 'dsh-kanban: board route')
907
922
 
923
+ ctx.effect(() => ctx.webServer.register({
924
+ kind: 'exact',
925
+ path: '/dsh-kanban/remote/test',
926
+ handler: async (req, res) => {
927
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
928
+ if (rejectUntrusted(req, res)) return
929
+ const cfg = getConfig()
930
+ const sshRes = await testRemoteConnection(cfg)
931
+ const serverRes = cfg.remoteServerUrl ? await testRemoteServer(cfg) : { ok: false, error: 'no-server-url' }
932
+ writeJson(res, 200, {
933
+ remoteMode: isRemoteMode(cfg),
934
+ ssh: sshRes,
935
+ server: serverRes,
936
+ })
937
+ },
938
+ }), 'dsh-kanban: remote connection test')
939
+
940
+ ctx.effect(() => ctx.webServer.register({
941
+ kind: 'exact',
942
+ path: '/api/dsh-kanban/remote/test',
943
+ handler: async (req, res) => {
944
+ if (req.method !== 'POST') { writeJson(res, 405, { error: 'method-not-allowed' }); return }
945
+ if (rejectUntrusted(req, res)) return
946
+ const cfg = getConfig()
947
+ const sshRes = await testRemoteConnection(cfg)
948
+ const serverRes = cfg.remoteServerUrl ? await testRemoteServer(cfg) : { ok: false, error: 'no-server-url' }
949
+ writeJson(res, 200, {
950
+ remoteMode: isRemoteMode(cfg),
951
+ ssh: sshRes,
952
+ server: serverRes,
953
+ })
954
+ },
955
+ }), 'dsh-kanban: remote connection test alias')
956
+
957
+
908
958
  // Snapshot endpoint handles both export and import.
909
959
  /**
910
960
  * Cached identity for Gitea client to avoid redundant API lookups on task claim.
@@ -1227,6 +1277,7 @@ export function apply(ctx, config) {
1227
1277
  workspaceRegistry: ctx.get('workspaceRegistry'),
1228
1278
  force: input?.force === true,
1229
1279
  cleanupTaskWorktree,
1280
+ gitRunner: getGitRunner(),
1230
1281
  config: getConfig(),
1231
1282
  repoDir: rootOf(getConfig()).path,
1232
1283
  cwdOf: () => rootOf(getConfig()).path,
@@ -1239,6 +1290,7 @@ export function apply(ctx, config) {
1239
1290
  task: store.getTask(id),
1240
1291
  file: input?.file,
1241
1292
  discardTaskFile,
1293
+ gitRunner: getGitRunner(),
1242
1294
  })
1243
1295
  if (resObj?.body?.success) {
1244
1296
  eventHub.broadcast('task:change', { taskId: id, action: 'discard', file: input?.file })
@@ -1259,12 +1311,19 @@ export function apply(ctx, config) {
1259
1311
  return
1260
1312
  }
1261
1313
  if (req.method === 'GET' && action === 'diff') {
1262
- const resObj = await handleTaskDiff({ task: store.getTask(id), getTaskDiff, getTaskDiffInspector })
1314
+ const resObj = await handleTaskDiff({
1315
+ task: store.getTask(id),
1316
+ getTaskDiff: (opts) => getTaskDiff({ ...opts, gitRunner: getGitRunner() }),
1317
+ getTaskDiffInspector: (opts) => getTaskDiffInspector({ ...opts, gitRunner: getGitRunner() }),
1318
+ })
1263
1319
  writeJson(res, resObj.status, resObj.body)
1264
1320
  return
1265
1321
  }
1266
1322
  if (req.method === 'GET' && action === 'commits') {
1267
- const resObj = await handleTaskCommits({ task: store.getTask(id), getTaskCommits })
1323
+ const resObj = await handleTaskCommits({
1324
+ task: store.getTask(id),
1325
+ getTaskCommits: (opts) => getTaskCommits({ ...opts, gitRunner: getGitRunner() }),
1326
+ })
1268
1327
  writeJson(res, resObj.status, resObj.body)
1269
1328
  return
1270
1329
  }
@@ -1274,6 +1333,7 @@ export function apply(ctx, config) {
1274
1333
  targetBranch: input?.targetBranch,
1275
1334
  workspaceRegistry: ctx.get('workspaceRegistry'),
1276
1335
  mergeTaskWorktree,
1336
+ gitRunner: getGitRunner(),
1277
1337
  })
1278
1338
  writeJson(res, resObj.status, resObj.body)
1279
1339
  return
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Remote DSH server relay client (#390).
3
+ * Proxies board state, task actions, and sessions between PC client and server 111.
4
+ * Zero Cyrillic literals in lib/ according to Issue #366 standard.
5
+ */
6
+
7
+ /**
8
+ * Check whether requests should be relayed to remote DSH server.
9
+ *
10
+ * @param {object} config
11
+ * @returns {boolean}
12
+ */
13
+ export function shouldRelay(config) {
14
+ return Boolean(config && config.remoteMode && config.remoteServerUrl)
15
+ }
16
+
17
+ /**
18
+ * Resolve target URL on remote DSH server.
19
+ *
20
+ * @param {object} config
21
+ * @param {string} path
22
+ * @returns {string}
23
+ */
24
+ export function getRemoteServerUrl(config, path = '') {
25
+ const base = String(config?.remoteServerUrl || 'http://192.168.1.111:3080').trim().replace(/\/+$/, '')
26
+ const cleanPath = path ? (path.startsWith('/') ? path : '/' + path) : ''
27
+ return `${base}${cleanPath}`
28
+ }
29
+
30
+ /**
31
+ * Build request headers including DSH auth token and cookies.
32
+ *
33
+ * @param {object} config
34
+ * @param {object} [originalHeaders]
35
+ * @returns {Record<string, string>}
36
+ */
37
+ export function buildRemoteHeaders(config, originalHeaders = {}) {
38
+ const headers = {
39
+ 'Accept': 'application/json, text/plain, */*',
40
+ }
41
+
42
+ if (originalHeaders && typeof originalHeaders === 'object') {
43
+ if (originalHeaders['content-type']) {
44
+ headers['Content-Type'] = originalHeaders['content-type']
45
+ }
46
+ }
47
+
48
+ const token = String(config?.remoteServerToken || '').trim()
49
+ if (token) {
50
+ headers['Authorization'] = `Bearer ${token}`
51
+ headers['Cookie'] = `dsh_token=${token}`
52
+ }
53
+
54
+ return headers
55
+ }
56
+
57
+ /**
58
+ * Relay an HTTP request to remote DSH server.
59
+ *
60
+ * @param {{
61
+ * config: object,
62
+ * path: string,
63
+ * method?: string,
64
+ * body?: any,
65
+ * headers?: object,
66
+ * timeoutMs?: number
67
+ * }} options
68
+ * @returns {Promise<{ status: number, body: any, headers?: object }>}
69
+ */
70
+ export async function relayRequest({ config, path, urlPath, method = 'GET', body, headers, timeoutMs = 15000 }) {
71
+ const targetPath = urlPath || path || ''
72
+ const url = getRemoteServerUrl(config, targetPath)
73
+ const reqHeaders = buildRemoteHeaders(config, headers)
74
+
75
+ const fetchOptions = {
76
+ method,
77
+ headers: reqHeaders,
78
+ signal: AbortSignal.timeout(timeoutMs),
79
+ }
80
+
81
+ if (body !== undefined && body !== null && method !== 'GET' && method !== 'HEAD') {
82
+ if (typeof body === 'string') {
83
+ fetchOptions.body = body
84
+ if (!fetchOptions.headers['Content-Type']) {
85
+ fetchOptions.headers['Content-Type'] = 'application/json'
86
+ }
87
+ } else {
88
+ fetchOptions.body = JSON.stringify(body)
89
+ fetchOptions.headers['Content-Type'] = 'application/json'
90
+ }
91
+ }
92
+
93
+ try {
94
+ const res = await fetch(url, fetchOptions)
95
+ const contentType = res.headers.get('content-type') || ''
96
+ let parsedBody
97
+ if (contentType.includes('application/json')) {
98
+ parsedBody = await res.json()
99
+ } else {
100
+ parsedBody = await res.text()
101
+ }
102
+
103
+ return {
104
+ status: res.status,
105
+ body: parsedBody,
106
+ }
107
+ } catch (err) {
108
+ return {
109
+ status: 503,
110
+ body: {
111
+ error: 'remote-server-unavailable',
112
+ detail: `Failed to reach remote DSH server at ${url}: ${err.message}`,
113
+ },
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Probe remote DSH server connection and kanban board endpoint.
120
+ *
121
+ * @param {object} config
122
+ * @returns {Promise<{ ok: boolean, latencyMs?: number, status?: number, error?: string }>}
123
+ */
124
+ export async function testRemoteServer(config) {
125
+ const startTime = Date.now()
126
+ const res = await relayRequest({
127
+ config,
128
+ urlPath: '/dsh-kanban/board',
129
+ method: 'GET',
130
+ timeoutMs: 8000,
131
+ })
132
+
133
+ const latencyMs = Date.now() - startTime
134
+
135
+ if (res.status === 200) {
136
+ return {
137
+ ok: true,
138
+ latencyMs,
139
+ status: res.status,
140
+ }
141
+ }
142
+
143
+ return {
144
+ ok: false,
145
+ latencyMs,
146
+ status: res.status,
147
+ error: res.body?.detail || res.body?.error || `HTTP ${res.status}`,
148
+ }
149
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Remote SSH runner for git and worktree operations (#390).
3
+ * Allows DSH running on PC (Windows) to execute commands on remote server (111).
4
+ * Zero Cyrillic literals in lib/ according to Issue #366 standard.
5
+ */
6
+
7
+ import { execFile } from 'node:child_process'
8
+ import { promisify } from 'node:util'
9
+
10
+ const execFileAsync = promisify(execFile)
11
+
12
+ /**
13
+ * Check whether remote execution mode is enabled in config.
14
+ *
15
+ * @param {object} config
16
+ * @returns {boolean}
17
+ */
18
+ export function isRemoteMode(config) {
19
+ return Boolean(config && config.remoteMode)
20
+ }
21
+
22
+ /**
23
+ * Quote argument safely for remote POSIX shell execution.
24
+ *
25
+ * @param {string|number} s
26
+ * @returns {string}
27
+ */
28
+ export function quoteArg(s) {
29
+ return "'" + String(s ?? '').replace(/'/g, "'\\''") + "'"
30
+ }
31
+
32
+ /**
33
+ * Build arguments array for SSH CLI process.
34
+ *
35
+ * @param {object} config
36
+ * @param {string} remoteCommand
37
+ * @returns {string[]}
38
+ */
39
+ export function buildSshArgs(config, remoteCommand) {
40
+ const host = String(config?.remoteHost || '192.168.1.111').trim()
41
+ const port = Number(config?.remotePort) || 22
42
+ const user = String(config?.remoteUser || 'migrate').trim()
43
+ const keyPath = String(config?.remoteKeyPath || '').trim()
44
+ const sudoUser = config?.remoteSudoUser !== undefined ? String(config.remoteSudoUser).trim() : 'vadim'
45
+
46
+ const args = [
47
+ '-p', String(port),
48
+ '-o', 'BatchMode=yes',
49
+ '-o', 'StrictHostKeyChecking=accept-new',
50
+ '-o', 'ConnectTimeout=10',
51
+ ]
52
+
53
+ if (keyPath) {
54
+ args.push('-i', keyPath)
55
+ }
56
+
57
+ const target = `${user}@${host}`
58
+ args.push(target)
59
+
60
+ let fullCmd = remoteCommand
61
+ if (sudoUser) {
62
+ fullCmd = `sudo -u ${sudoUser} -H bash -c ${quoteArg(remoteCommand)}`
63
+ }
64
+
65
+ args.push(fullCmd)
66
+ return args
67
+ }
68
+
69
+ /**
70
+ * Create a git runner function that delegates to remote host over SSH when remoteMode is active.
71
+ * Conforms to (args, cwd) => Promise<{ stdout: string, stderr: string }>.
72
+ *
73
+ * @param {object} config
74
+ * @param {Function} [localGitRunner] fallback local runner
75
+ * @returns {Function}
76
+ */
77
+ export function createRemoteGitRunner(config, localGitRunner) {
78
+ return async function remoteGitRunner(args, cwd) {
79
+ if (!isRemoteMode(config)) {
80
+ if (typeof localGitRunner === 'function') {
81
+ return localGitRunner(args, cwd)
82
+ }
83
+ throw new Error('[dsh-kanban] No local git runner available in non-remote mode')
84
+ }
85
+
86
+ const escapedArgs = (Array.isArray(args) ? args : []).map(quoteArg).join(' ')
87
+ const cdPart = cwd ? `cd ${quoteArg(cwd)} && ` : ''
88
+ const gitCmd = `${cdPart}git ${escapedArgs}`
89
+ const sshArgs = buildSshArgs(config, gitCmd)
90
+
91
+ try {
92
+ const { stdout, stderr } = await execFileAsync('ssh', sshArgs, {
93
+ maxBuffer: 10 * 1024 * 1024,
94
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' },
95
+ })
96
+ return { stdout: stdout ?? '', stderr: stderr ?? '' }
97
+ } catch (err) {
98
+ const error = new Error(`[dsh-kanban-remote] git ${args?.[0] || ''} failed on ${config?.remoteHost || 'remote'}: ${err.message}`)
99
+ error.code = err.code ?? 1
100
+ error.stdout = err.stdout ?? ''
101
+ error.stderr = err.stderr ?? err.message ?? ''
102
+ throw error
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Check connectivity to remote server and verify remote root directory.
109
+ *
110
+ * @param {object} config
111
+ * @returns {Promise<{ ok: boolean, latencyMs?: number, remoteRootFound?: boolean, error?: string }>}
112
+ */
113
+ export async function testRemoteConnection(config) {
114
+ const root = String(config?.remoteRoot || '/mnt/external/Project/DEV').trim()
115
+ const probeCmd = `test -d ${quoteArg(root)} && echo REMOTE_ROOT_OK || echo REMOTE_ROOT_MISSING`
116
+ const sshArgs = buildSshArgs(config, probeCmd)
117
+
118
+ const startTime = Date.now()
119
+ try {
120
+ const { stdout } = await execFileAsync('ssh', sshArgs, {
121
+ maxBuffer: 1024 * 1024,
122
+ timeout: 12000,
123
+ env: { ...process.env, LC_ALL: 'C' },
124
+ })
125
+ const latencyMs = Date.now() - startTime
126
+ const trimmed = (stdout || '').trim()
127
+ const remoteRootFound = trimmed.includes('REMOTE_ROOT_OK')
128
+
129
+ return {
130
+ ok: true,
131
+ latencyMs,
132
+ remoteRootFound,
133
+ }
134
+ } catch (err) {
135
+ return {
136
+ ok: false,
137
+ error: err.stderr ? err.stderr.trim() : err.message,
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Check if path exists on remote host.
144
+ *
145
+ * @param {string} remotePath
146
+ * @param {object} config
147
+ * @returns {Promise<boolean>}
148
+ */
149
+ export async function remotePathExists(remotePath, config) {
150
+ if (!remotePath) return false
151
+ const probeCmd = `test -e ${quoteArg(remotePath)} && echo 1 || echo 0`
152
+ const sshArgs = buildSshArgs(config, probeCmd)
153
+
154
+ try {
155
+ const { stdout } = await execFileAsync('ssh', sshArgs, {
156
+ maxBuffer: 1024 * 1024,
157
+ timeout: 10000,
158
+ env: { ...process.env, LC_ALL: 'C' },
159
+ })
160
+ return (stdout || '').trim() === '1'
161
+ } catch {
162
+ return false
163
+ }
164
+ }
package/lib/routes.js CHANGED
@@ -398,6 +398,7 @@ export function buildBoard({ store, config, board = 'main', repo, liveOf, sync,
398
398
  // Sync telemetry state attached directly to board snapshot.
399
399
  sync: sync ?? { state: 'never' },
400
400
  costMeterAvailable: Boolean(costMeterAvailable),
401
+ remoteMode: Boolean(config?.remoteMode),
401
402
  }
402
403
  }
403
404
 
@@ -767,7 +768,7 @@ export function exportTasksCsv({ store, board = 'main' }) {
767
768
  */
768
769
  export async function handleCleanupWorktree({
769
770
  store, task, workspaceRegistry, force, cleanupTaskWorktree,
770
- config, repoDir, cwdOf, env,
771
+ config, repoDir, cwdOf, env, gitRunner,
771
772
  }) {
772
773
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
773
774
  const out = await cleanupTaskWorktree({
@@ -779,6 +780,7 @@ export async function handleCleanupWorktree({
779
780
  repoDir,
780
781
  cwdOf,
781
782
  env,
783
+ gitRunner,
782
784
  })
783
785
  const status = out.status || (out.error ? (out.error === 'unfenced-worktree-path' ? 403 : 500) : 200)
784
786
  return { status, body: out }
@@ -800,32 +802,33 @@ export async function handleTaskDiff({ task, getTaskDiff, getTaskDiffInspector }
800
802
  /**
801
803
  * Discard file changes in task worktree (#371).
802
804
  */
803
- export async function handleTaskDiscard({ task, file, discardTaskFile }) {
805
+ export async function handleTaskDiscard({ task, file, discardTaskFile, gitRunner }) {
804
806
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
805
807
  if (!file || typeof file !== 'string') return { status: 400, body: { error: 'file-required' } }
806
808
  if (!task.worktree) return { status: 400, body: { error: 'no-worktree' } }
807
- const result = await discardTaskFile({ worktreePath: task.worktree, file })
809
+ const result = await discardTaskFile({ worktreePath: task.worktree, file, gitRunner })
808
810
  return { status: result.success ? 200 : 400, body: result }
809
811
  }
810
812
 
811
813
  /**
812
814
  * Get commit list for task.
813
815
  */
814
- export async function handleTaskCommits({ task, getTaskCommits }) {
816
+ export async function handleTaskCommits({ task, getTaskCommits, gitRunner }) {
815
817
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
816
- const commits = await getTaskCommits({ worktreePath: task.worktree })
818
+ const commits = await getTaskCommits({ worktreePath: task.worktree, gitRunner })
817
819
  return { status: 200, body: { commits } }
818
820
  }
819
821
 
820
822
  /**
821
823
  * Merge task worktree branch and transition to done.
822
824
  */
823
- export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree }) {
825
+ export async function handleTaskMerge({ store, task, targetBranch, workspaceRegistry, mergeTaskWorktree, gitRunner }) {
824
826
  if (!task) return { status: 404, body: { error: 'task-not-found' } }
825
827
  const mergeResult = await mergeTaskWorktree({
826
828
  task,
827
829
  targetBranch: typeof targetBranch === 'string' ? targetBranch.trim() : undefined,
828
830
  workspaceRegistry,
831
+ gitRunner,
829
832
  })
830
833
  if (!mergeResult.success) {
831
834
  return {
package/lib/worktree.js CHANGED
@@ -71,7 +71,32 @@ export function safeRepoKey(task) {
71
71
  /**
72
72
  * Resolve root directory for all managed worktrees.
73
73
  */
74
+ /**
75
+ * Check if worktree directory exists locally or remotely.
76
+ *
77
+ * @param {string} worktreePath
78
+ * @param {Function} [gitRunner]
79
+ * @returns {Promise<boolean>}
80
+ */
81
+ export async function safeWorktreeExists(worktreePath, gitRunner) {
82
+ if (!worktreePath) return false
83
+ if (existsSync(worktreePath)) return true
84
+ if (typeof gitRunner === 'function') {
85
+ try {
86
+ const res = await gitRunner(['rev-parse', '--is-inside-work-tree'], worktreePath)
87
+ return res.stdout?.trim() === 'true'
88
+ } catch {
89
+ return false
90
+ }
91
+ }
92
+ return false
93
+ }
94
+
74
95
  export function resolveWorktreeRoot(config, env = process.env, cwdOf = () => process.cwd()) {
96
+ if (config?.remoteMode && config?.remoteRoot) {
97
+ const root = String(config.remoteRoot).trim().replace(/\/+$/, '')
98
+ return `${root}/.worktrees`
99
+ }
75
100
  const custom = typeof config?.worktreeRoot === 'string' ? config.worktreeRoot.trim() : ''
76
101
  if (custom !== '') {
77
102
  return isAbsolute(custom) ? resolve(custom) : resolve(join(cwdOf(), custom))
@@ -105,6 +130,12 @@ export function isPathFenced(targetPath, rootPath) {
105
130
  }
106
131
 
107
132
  export function resolveTaskWorktreePath({ task, config, env, cwdOf }) {
133
+ if (config?.remoteMode) {
134
+ const root = resolveWorktreeRoot(config, env, cwdOf)
135
+ const repoKey = safeRepoKey(task)
136
+ const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
137
+ return `${root}/${repoKey}/${taskId}`
138
+ }
108
139
  const root = resolveWorktreeRoot(config, env, cwdOf)
109
140
  const repoKey = safeRepoKey(task)
110
141
  const taskId = typeof task?.id === 'string' && task.id.trim() !== '' ? task.id.trim() : 'task'
@@ -261,7 +292,8 @@ function isSubmoduleDrift(worktreePath, line) {
261
292
  * Excludes submodule noise and gitlink drift (#213).
262
293
  */
263
294
  export async function checkWorktreeDirty({ worktreePath, gitRunner = defaultGitRunner }) {
264
- if (!worktreePath || !existsSync(worktreePath)) {
295
+ const exists = await safeWorktreeExists(worktreePath, gitRunner)
296
+ if (!worktreePath || !exists) {
265
297
  return { dirty: false, files: [] }
266
298
  }
267
299
  try {
@@ -289,7 +321,8 @@ export async function removeTaskWorktree({
289
321
  gitRunner = defaultGitRunner,
290
322
  rootPath,
291
323
  }) {
292
- if (!worktreePath || !existsSync(worktreePath)) {
324
+ const exists = await safeWorktreeExists(worktreePath, gitRunner)
325
+ if (!worktreePath || !exists) {
293
326
  return { removed: false, reason: 'not-found' }
294
327
  }
295
328
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-kanban",
3
- "version": "0.2.33",
3
+ "version": "0.2.34",
4
4
  "description": "Visual Kanban board for DeepSeek Harness: Gitea and GitHub-backed tasks, workflow columns, and a dedicated agent session per task.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",