github-issue-tower-defence-management 1.97.4 → 1.98.0
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/CHANGELOG.md +7 -0
- package/README.md +12 -0
- package/bin/adapter/entry-points/cli/index.js +62 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js +48 -0
- package/bin/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.js.map +1 -0
- package/bin/adapter/repositories/ProcClaudeInteractiveSessionRepository.js +145 -0
- package/bin/adapter/repositories/ProcClaudeInteractiveSessionRepository.js.map +1 -0
- package/bin/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.js +41 -0
- package/bin/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.ts +120 -0
- package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.test.ts +123 -0
- package/src/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.ts +77 -0
- package/src/adapter/repositories/ProcClaudeInteractiveSessionRepository.test.ts +184 -0
- package/src/adapter/repositories/ProcClaudeInteractiveSessionRepository.ts +138 -0
- package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.test.ts +169 -0
- package/src/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.ts +63 -0
- package/src/domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository.ts +9 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.d.ts +18 -0
- package/types/adapter/entry-points/handlers/InTmuxByHumanSessionTokenCountHandler.d.ts.map +1 -0
- package/types/adapter/repositories/ProcClaudeInteractiveSessionRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcClaudeInteractiveSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.d.ts +17 -0
- package/types/domain/usecases/InTmuxByHumanSessionTokenCountUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository.d.ts +9 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository.d.ts.map +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { Issue } from '../../../domain/entities/Issue';
|
|
5
|
+
import {
|
|
6
|
+
AWAITING_QUALITY_CHECK_STATUS_NAME,
|
|
7
|
+
IN_TMUX_STATUS_NAME,
|
|
8
|
+
} from '../../../domain/entities/WorkflowStatus';
|
|
9
|
+
import {
|
|
10
|
+
ClaudeInteractiveSession,
|
|
11
|
+
ClaudeInteractiveSessionRepository,
|
|
12
|
+
} from '../../../domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository';
|
|
13
|
+
import { InTmuxByHumanSessionTokenCountUseCase } from '../../../domain/usecases/InTmuxByHumanSessionTokenCountUseCase';
|
|
14
|
+
import { InTmuxByHumanSessionTokenCountHandler } from './InTmuxByHumanSessionTokenCountHandler';
|
|
15
|
+
|
|
16
|
+
class FakeClaudeInteractiveSessionRepository implements ClaudeInteractiveSessionRepository {
|
|
17
|
+
constructor(private readonly sessions: ClaudeInteractiveSession[]) {}
|
|
18
|
+
listInteractiveSessions = (): ClaudeInteractiveSession[] => this.sessions;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const issueUrlA = 'https://github.com/HiromiShikata/example/issues/1';
|
|
22
|
+
const issueUrlB = 'https://github.com/HiromiShikata/example/issues/2';
|
|
23
|
+
|
|
24
|
+
const issue = (url: string, status: string): Issue => ({
|
|
25
|
+
nameWithOwner: 'HiromiShikata/example',
|
|
26
|
+
number: 1,
|
|
27
|
+
title: 'Example issue',
|
|
28
|
+
state: 'OPEN',
|
|
29
|
+
status,
|
|
30
|
+
story: null,
|
|
31
|
+
nextActionDate: null,
|
|
32
|
+
nextActionHour: null,
|
|
33
|
+
estimationMinutes: null,
|
|
34
|
+
dependedIssueUrls: [],
|
|
35
|
+
completionDate50PercentConfidence: null,
|
|
36
|
+
url,
|
|
37
|
+
assignees: ['hiromi'],
|
|
38
|
+
labels: [],
|
|
39
|
+
org: 'HiromiShikata',
|
|
40
|
+
repo: 'example',
|
|
41
|
+
body: '',
|
|
42
|
+
itemId: 'item-1',
|
|
43
|
+
isPr: false,
|
|
44
|
+
isInProgress: false,
|
|
45
|
+
isClosed: false,
|
|
46
|
+
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
47
|
+
author: 'hiromi',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('InTmuxByHumanSessionTokenCountHandler', () => {
|
|
51
|
+
let tokenListPath: string;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'token-list-'));
|
|
55
|
+
tokenListPath = path.join(directory, 'tokens.json');
|
|
56
|
+
fs.writeFileSync(
|
|
57
|
+
tokenListPath,
|
|
58
|
+
JSON.stringify([
|
|
59
|
+
{ name: 'alpha', token: 'token-alpha' },
|
|
60
|
+
{ name: 'beta', token: 'token-beta' },
|
|
61
|
+
]),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
fs.rmSync(path.dirname(tokenListPath), { recursive: true, force: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('writes one tab-separated line per token with In-Tmux-by-human counts', () => {
|
|
70
|
+
const handler = new InTmuxByHumanSessionTokenCountHandler(
|
|
71
|
+
new InTmuxByHumanSessionTokenCountUseCase(),
|
|
72
|
+
new FakeClaudeInteractiveSessionRepository([
|
|
73
|
+
{ token: 'token-alpha', sessionId: 'session-a', issueUrl: issueUrlA },
|
|
74
|
+
{ token: 'token-alpha', sessionId: 'session-a', issueUrl: issueUrlA },
|
|
75
|
+
{ token: 'token-beta', sessionId: 'session-b', issueUrl: issueUrlB },
|
|
76
|
+
]),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const output = handler.handle({
|
|
80
|
+
tokenListJsonPath: tokenListPath,
|
|
81
|
+
issues: [
|
|
82
|
+
issue(issueUrlA, IN_TMUX_STATUS_NAME),
|
|
83
|
+
issue(issueUrlB, AWAITING_QUALITY_CHECK_STATUS_NAME),
|
|
84
|
+
],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(output.lines).toEqual(['alpha\t1', 'beta\t0']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('reports a diagnostic when no token list path is available', () => {
|
|
91
|
+
const handler = new InTmuxByHumanSessionTokenCountHandler(
|
|
92
|
+
new InTmuxByHumanSessionTokenCountUseCase(),
|
|
93
|
+
new FakeClaudeInteractiveSessionRepository([]),
|
|
94
|
+
);
|
|
95
|
+
const previous = process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
|
|
96
|
+
delete process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH;
|
|
97
|
+
|
|
98
|
+
const output = handler.handle({ tokenListJsonPath: null, issues: [] });
|
|
99
|
+
|
|
100
|
+
expect(output.lines).toEqual([]);
|
|
101
|
+
expect(output.diagnostics[0]).toContain('No token list path provided');
|
|
102
|
+
|
|
103
|
+
if (previous !== undefined) {
|
|
104
|
+
process.env.CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH = previous;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('reports a diagnostic when the token list file has no usable entries', () => {
|
|
109
|
+
fs.writeFileSync(tokenListPath, JSON.stringify([]));
|
|
110
|
+
const handler = new InTmuxByHumanSessionTokenCountHandler(
|
|
111
|
+
new InTmuxByHumanSessionTokenCountUseCase(),
|
|
112
|
+
new FakeClaudeInteractiveSessionRepository([]),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const output = handler.handle({
|
|
116
|
+
tokenListJsonPath: tokenListPath,
|
|
117
|
+
issues: [],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(output.lines).toEqual([]);
|
|
121
|
+
expect(output.diagnostics[0]).toContain('No usable token entries');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Issue } from '../../../domain/entities/Issue';
|
|
2
|
+
import { ClaudeInteractiveSessionRepository } from '../../../domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository';
|
|
3
|
+
import { InTmuxByHumanSessionTokenCountUseCase } from '../../../domain/usecases/InTmuxByHumanSessionTokenCountUseCase';
|
|
4
|
+
import { OauthTokenCandidate } from '../../../domain/usecases/OauthTokenSelectUseCase';
|
|
5
|
+
import { ProcClaudeInteractiveSessionRepository } from '../../repositories/ProcClaudeInteractiveSessionRepository';
|
|
6
|
+
import { loadTokenEntries } from '../../proxy/TokenListLoader';
|
|
7
|
+
import { resolveTokenListJsonPath } from './OauthTokenSelectHandler';
|
|
8
|
+
|
|
9
|
+
export type InTmuxByHumanSessionTokenCountHandlerInput = {
|
|
10
|
+
tokenListJsonPath: string | null;
|
|
11
|
+
issues: Issue[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type InTmuxByHumanSessionTokenCountHandlerOutput = {
|
|
15
|
+
lines: string[];
|
|
16
|
+
diagnostics: string[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class InTmuxByHumanSessionTokenCountHandler {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly useCase: InTmuxByHumanSessionTokenCountUseCase = new InTmuxByHumanSessionTokenCountUseCase(),
|
|
22
|
+
private readonly interactiveSessionRepository: ClaudeInteractiveSessionRepository = new ProcClaudeInteractiveSessionRepository(),
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
handle = (
|
|
26
|
+
input: InTmuxByHumanSessionTokenCountHandlerInput,
|
|
27
|
+
): InTmuxByHumanSessionTokenCountHandlerOutput => {
|
|
28
|
+
const tokenListJsonPath = resolveTokenListJsonPath(input.tokenListJsonPath);
|
|
29
|
+
if (tokenListJsonPath === null) {
|
|
30
|
+
return {
|
|
31
|
+
lines: [],
|
|
32
|
+
diagnostics: [
|
|
33
|
+
'No token list path provided. Pass --tokenListJsonPath or set CLAUDE_CODE_OAUTH_TOKEN_LIST_JSON_PATH.',
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entries = loadTokenEntries(tokenListJsonPath);
|
|
39
|
+
if (entries === null) {
|
|
40
|
+
return {
|
|
41
|
+
lines: [],
|
|
42
|
+
diagnostics: [
|
|
43
|
+
`No usable token entries loaded from ${tokenListJsonPath}.`,
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const candidates: OauthTokenCandidate[] = entries.map(
|
|
49
|
+
({ name, token }) => ({
|
|
50
|
+
name,
|
|
51
|
+
token,
|
|
52
|
+
snapshot: null,
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const interactiveSessions =
|
|
57
|
+
this.interactiveSessionRepository.listInteractiveSessions();
|
|
58
|
+
|
|
59
|
+
const result = this.useCase.run(
|
|
60
|
+
candidates,
|
|
61
|
+
interactiveSessions,
|
|
62
|
+
input.issues,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const lines = result.counts.map((count) => `${count.name}\t${count.count}`);
|
|
66
|
+
|
|
67
|
+
const totalSessions = result.counts.reduce(
|
|
68
|
+
(sum, count) => sum + count.count,
|
|
69
|
+
0,
|
|
70
|
+
);
|
|
71
|
+
const diagnostics = [
|
|
72
|
+
`Counted ${totalSessions} live In-Tmux-by-human session(s) across ${result.counts.length} token(s).`,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
return { lines, diagnostics };
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ProcClaudeInteractiveSessionRepository } from './ProcClaudeInteractiveSessionRepository';
|
|
5
|
+
|
|
6
|
+
type FakeProcess = {
|
|
7
|
+
pid: number;
|
|
8
|
+
cmdline: string;
|
|
9
|
+
environ: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const issueUrl = 'https://github.com/HiromiShikata/example/issues/1';
|
|
13
|
+
|
|
14
|
+
const argv = (...parts: string[]): string => `${parts.join('\0')}\0`;
|
|
15
|
+
|
|
16
|
+
describe('ProcClaudeInteractiveSessionRepository', () => {
|
|
17
|
+
let procDirectory: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-int-'));
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const writeProcess = (fakeProcess: FakeProcess): void => {
|
|
28
|
+
const processDirectory = path.join(procDirectory, String(fakeProcess.pid));
|
|
29
|
+
fs.mkdirSync(processDirectory, { recursive: true });
|
|
30
|
+
fs.writeFileSync(
|
|
31
|
+
path.join(processDirectory, 'cmdline'),
|
|
32
|
+
fakeProcess.cmdline,
|
|
33
|
+
);
|
|
34
|
+
const environBuffer = Object.entries(fakeProcess.environ)
|
|
35
|
+
.map(([key, value]) => `${key}=${value}\0`)
|
|
36
|
+
.join('');
|
|
37
|
+
fs.writeFileSync(path.join(processDirectory, 'environ'), environBuffer);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it('reads token, session id and issue url from a cl-launched interactive process', () => {
|
|
41
|
+
writeProcess({
|
|
42
|
+
pid: 201,
|
|
43
|
+
cmdline: argv('claude', '--model', 'opus', '--name', issueUrl),
|
|
44
|
+
environ: {
|
|
45
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-a',
|
|
46
|
+
CLAUDE_CODE_SESSION_ID: 'session-a',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
51
|
+
procDirectory,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
expect(repository.listInteractiveSessions()).toEqual([
|
|
55
|
+
{ token: 'token-a', sessionId: 'session-a', issueUrl },
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('ignores a process without a --name issue url argument', () => {
|
|
60
|
+
writeProcess({
|
|
61
|
+
pid: 202,
|
|
62
|
+
cmdline: argv('claude', '--model', 'opus'),
|
|
63
|
+
environ: {
|
|
64
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-b',
|
|
65
|
+
CLAUDE_CODE_SESSION_ID: 'session-b',
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
70
|
+
procDirectory,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(repository.listInteractiveSessions()).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('excludes a Take ownership aw spawn even when it carries the token', () => {
|
|
77
|
+
writeProcess({
|
|
78
|
+
pid: 203,
|
|
79
|
+
cmdline: argv(
|
|
80
|
+
'claude-agent',
|
|
81
|
+
'--agent',
|
|
82
|
+
'impl',
|
|
83
|
+
'-p',
|
|
84
|
+
`Take ownership of ${issueUrl} and finish it`,
|
|
85
|
+
),
|
|
86
|
+
environ: {
|
|
87
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-c',
|
|
88
|
+
CLAUDE_CODE_SESSION_ID: 'session-c',
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
93
|
+
procDirectory,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(repository.listInteractiveSessions()).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('ignores a --name process without an oauth token', () => {
|
|
100
|
+
writeProcess({
|
|
101
|
+
pid: 204,
|
|
102
|
+
cmdline: argv('claude', '--name', issueUrl),
|
|
103
|
+
environ: {
|
|
104
|
+
CLAUDE_CODE_SESSION_ID: 'session-d',
|
|
105
|
+
ANTHROPIC_API_KEY: 'api-key',
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
110
|
+
procDirectory,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(repository.listInteractiveSessions()).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('ignores a --name process without a session id', () => {
|
|
117
|
+
writeProcess({
|
|
118
|
+
pid: 205,
|
|
119
|
+
cmdline: argv('claude', '--name', issueUrl),
|
|
120
|
+
environ: {
|
|
121
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-e',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
126
|
+
procDirectory,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
expect(repository.listInteractiveSessions()).toEqual([]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('ignores a --name value that is not an http url', () => {
|
|
133
|
+
writeProcess({
|
|
134
|
+
pid: 206,
|
|
135
|
+
cmdline: argv('claude', '--name', 'just-a-label'),
|
|
136
|
+
environ: {
|
|
137
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-f',
|
|
138
|
+
CLAUDE_CODE_SESSION_ID: 'session-f',
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
143
|
+
procDirectory,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(repository.listInteractiveSessions()).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('returns one entry per child process so the use case can dedupe by session id', () => {
|
|
150
|
+
writeProcess({
|
|
151
|
+
pid: 207,
|
|
152
|
+
cmdline: argv('claude', '--name', issueUrl),
|
|
153
|
+
environ: {
|
|
154
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-g',
|
|
155
|
+
CLAUDE_CODE_SESSION_ID: 'session-g',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
writeProcess({
|
|
159
|
+
pid: 208,
|
|
160
|
+
cmdline: argv('claude', '--name', issueUrl),
|
|
161
|
+
environ: {
|
|
162
|
+
CLAUDE_CODE_OAUTH_TOKEN: 'token-g',
|
|
163
|
+
CLAUDE_CODE_SESSION_ID: 'session-g',
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
168
|
+
procDirectory,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
expect(repository.listInteractiveSessions()).toEqual([
|
|
172
|
+
{ token: 'token-g', sessionId: 'session-g', issueUrl },
|
|
173
|
+
{ token: 'token-g', sessionId: 'session-g', issueUrl },
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns an empty list when the proc directory does not exist', () => {
|
|
178
|
+
const repository = new ProcClaudeInteractiveSessionRepository(
|
|
179
|
+
path.join(procDirectory, 'missing'),
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(repository.listInteractiveSessions()).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
ClaudeInteractiveSession,
|
|
5
|
+
ClaudeInteractiveSessionRepository,
|
|
6
|
+
} from '../../domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
9
|
+
const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
10
|
+
const SESSION_ID_ENVIRON_KEY = 'CLAUDE_CODE_SESSION_ID';
|
|
11
|
+
const NAME_ARGUMENT = '--name';
|
|
12
|
+
const TAKE_OWNERSHIP_MARKER = 'Take ownership';
|
|
13
|
+
|
|
14
|
+
const isIssueUrl = (value: string): boolean =>
|
|
15
|
+
value.startsWith('http://') || value.startsWith('https://');
|
|
16
|
+
|
|
17
|
+
const parseCommandLineArguments = (cmdline: string): string[] =>
|
|
18
|
+
cmdline.split('\0').filter((argument) => argument.length > 0);
|
|
19
|
+
|
|
20
|
+
const extractIssueUrl = (commandArguments: string[]): string | null => {
|
|
21
|
+
for (let index = 0; index < commandArguments.length - 1; index += 1) {
|
|
22
|
+
if (commandArguments[index] === NAME_ARGUMENT) {
|
|
23
|
+
const value = commandArguments[index + 1] ?? '';
|
|
24
|
+
if (isIssueUrl(value)) {
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const isTakeOwnershipSpawn = (commandArguments: string[]): boolean =>
|
|
33
|
+
commandArguments.some((argument) => argument.includes(TAKE_OWNERSHIP_MARKER));
|
|
34
|
+
|
|
35
|
+
export class ProcClaudeInteractiveSessionRepository implements ClaudeInteractiveSessionRepository {
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
listInteractiveSessions = (): ClaudeInteractiveSession[] => {
|
|
41
|
+
const interactiveSessions: ClaudeInteractiveSession[] = [];
|
|
42
|
+
for (const pidDirectory of this.listProcessIdDirectories()) {
|
|
43
|
+
const interactiveSession = this.readInteractiveSession(pidDirectory);
|
|
44
|
+
if (interactiveSession !== null) {
|
|
45
|
+
interactiveSessions.push(interactiveSession);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return interactiveSessions;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
private listProcessIdDirectories = (): string[] => {
|
|
52
|
+
let entries: string[];
|
|
53
|
+
try {
|
|
54
|
+
entries = fs.readdirSync(this.procDirectory);
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return entries.filter((entry) => /^\d+$/.test(entry));
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
private readInteractiveSession = (
|
|
62
|
+
processIdDirectory: string,
|
|
63
|
+
): ClaudeInteractiveSession | null => {
|
|
64
|
+
const commandArguments = this.readCommandArguments(processIdDirectory);
|
|
65
|
+
if (commandArguments === null) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (isTakeOwnershipSpawn(commandArguments)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const issueUrl = extractIssueUrl(commandArguments);
|
|
72
|
+
if (issueUrl === null) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const environ = this.readEnviron(processIdDirectory);
|
|
76
|
+
if (environ === null) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const token = environ.get(OAUTH_TOKEN_ENVIRON_KEY);
|
|
80
|
+
const sessionId = environ.get(SESSION_ID_ENVIRON_KEY);
|
|
81
|
+
if (
|
|
82
|
+
token === undefined ||
|
|
83
|
+
token.length === 0 ||
|
|
84
|
+
sessionId === undefined ||
|
|
85
|
+
sessionId.length === 0
|
|
86
|
+
) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return { token, sessionId, issueUrl };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
private readCommandArguments = (
|
|
93
|
+
processIdDirectory: string,
|
|
94
|
+
): string[] | null => {
|
|
95
|
+
const cmdlinePath = path.join(
|
|
96
|
+
this.procDirectory,
|
|
97
|
+
processIdDirectory,
|
|
98
|
+
'cmdline',
|
|
99
|
+
);
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = fs.readFileSync(cmdlinePath, 'utf8');
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
return parseCommandLineArguments(raw);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
private readEnviron = (
|
|
110
|
+
processIdDirectory: string,
|
|
111
|
+
): Map<string, string> | null => {
|
|
112
|
+
const environPath = path.join(
|
|
113
|
+
this.procDirectory,
|
|
114
|
+
processIdDirectory,
|
|
115
|
+
'environ',
|
|
116
|
+
);
|
|
117
|
+
let raw: string;
|
|
118
|
+
try {
|
|
119
|
+
raw = fs.readFileSync(environPath, 'utf8');
|
|
120
|
+
} catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const environ = new Map<string, string>();
|
|
124
|
+
for (const entry of raw.split('\0')) {
|
|
125
|
+
if (entry.length === 0) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const separatorIndex = entry.indexOf('=');
|
|
129
|
+
if (separatorIndex <= 0) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const key = entry.slice(0, separatorIndex);
|
|
133
|
+
const value = entry.slice(separatorIndex + 1);
|
|
134
|
+
environ.set(key, value);
|
|
135
|
+
}
|
|
136
|
+
return environ;
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { Issue } from '../entities/Issue';
|
|
2
|
+
import {
|
|
3
|
+
AWAITING_QUALITY_CHECK_STATUS_NAME,
|
|
4
|
+
IN_TMUX_STATUS_NAME,
|
|
5
|
+
} from '../entities/WorkflowStatus';
|
|
6
|
+
import { ClaudeInteractiveSession } from './adapter-interfaces/ClaudeInteractiveSessionRepository';
|
|
7
|
+
import { InTmuxByHumanSessionTokenCountUseCase } from './InTmuxByHumanSessionTokenCountUseCase';
|
|
8
|
+
import { OauthTokenCandidate } from './OauthTokenSelectUseCase';
|
|
9
|
+
|
|
10
|
+
const candidate = (name: string): OauthTokenCandidate => ({
|
|
11
|
+
name,
|
|
12
|
+
token: `fake-token-${name}`,
|
|
13
|
+
snapshot: null,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const session = (
|
|
17
|
+
name: string,
|
|
18
|
+
sessionId: string,
|
|
19
|
+
issueUrl: string,
|
|
20
|
+
): ClaudeInteractiveSession => ({
|
|
21
|
+
token: `fake-token-${name}`,
|
|
22
|
+
sessionId,
|
|
23
|
+
issueUrl,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const issue = (url: string, status: string): Issue => ({
|
|
27
|
+
nameWithOwner: 'HiromiShikata/example',
|
|
28
|
+
number: 1,
|
|
29
|
+
title: 'Example issue',
|
|
30
|
+
state: 'OPEN',
|
|
31
|
+
status,
|
|
32
|
+
story: null,
|
|
33
|
+
nextActionDate: null,
|
|
34
|
+
nextActionHour: null,
|
|
35
|
+
estimationMinutes: null,
|
|
36
|
+
dependedIssueUrls: [],
|
|
37
|
+
completionDate50PercentConfidence: null,
|
|
38
|
+
url,
|
|
39
|
+
assignees: ['hiromi'],
|
|
40
|
+
labels: [],
|
|
41
|
+
org: 'HiromiShikata',
|
|
42
|
+
repo: 'example',
|
|
43
|
+
body: '',
|
|
44
|
+
itemId: 'item-1',
|
|
45
|
+
isPr: false,
|
|
46
|
+
isInProgress: false,
|
|
47
|
+
isClosed: false,
|
|
48
|
+
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
49
|
+
author: 'hiromi',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('InTmuxByHumanSessionTokenCountUseCase', () => {
|
|
53
|
+
const useCase = new InTmuxByHumanSessionTokenCountUseCase();
|
|
54
|
+
const issueUrlA = 'https://github.com/HiromiShikata/example/issues/1';
|
|
55
|
+
const issueUrlB = 'https://github.com/HiromiShikata/example/issues/2';
|
|
56
|
+
|
|
57
|
+
it('counts a session whose issue is in In-Tmux-by-human status', () => {
|
|
58
|
+
const result = useCase.run(
|
|
59
|
+
[candidate('alpha')],
|
|
60
|
+
[session('alpha', 'session-a', issueUrlA)],
|
|
61
|
+
[issue(issueUrlA, IN_TMUX_STATUS_NAME)],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const alpha = result.counts.find((entry) => entry.name === 'alpha');
|
|
65
|
+
expect(alpha?.count).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('does not count a session whose issue is in another status', () => {
|
|
69
|
+
const result = useCase.run(
|
|
70
|
+
[candidate('alpha')],
|
|
71
|
+
[session('alpha', 'session-a', issueUrlA)],
|
|
72
|
+
[issue(issueUrlA, AWAITING_QUALITY_CHECK_STATUS_NAME)],
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const alpha = result.counts.find((entry) => entry.name === 'alpha');
|
|
76
|
+
expect(alpha?.count).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not count a session whose issue is closed even in In-Tmux-by-human status', () => {
|
|
80
|
+
const closedIssue: Issue = {
|
|
81
|
+
...issue(issueUrlA, IN_TMUX_STATUS_NAME),
|
|
82
|
+
isClosed: true,
|
|
83
|
+
};
|
|
84
|
+
const result = useCase.run(
|
|
85
|
+
[candidate('alpha')],
|
|
86
|
+
[session('alpha', 'session-a', issueUrlA)],
|
|
87
|
+
[closedIssue],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const alpha = result.counts.find((entry) => entry.name === 'alpha');
|
|
91
|
+
expect(alpha?.count).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('dedupes distinct sessions sharing one session id under the same token', () => {
|
|
95
|
+
const result = useCase.run(
|
|
96
|
+
[candidate('alpha')],
|
|
97
|
+
[
|
|
98
|
+
session('alpha', 'session-a', issueUrlA),
|
|
99
|
+
session('alpha', 'session-a', issueUrlA),
|
|
100
|
+
session('alpha', 'session-a', issueUrlA),
|
|
101
|
+
],
|
|
102
|
+
[issue(issueUrlA, IN_TMUX_STATUS_NAME)],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const alpha = result.counts.find((entry) => entry.name === 'alpha');
|
|
106
|
+
expect(alpha?.count).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('counts distinct session ids separately under the same token', () => {
|
|
110
|
+
const result = useCase.run(
|
|
111
|
+
[candidate('alpha')],
|
|
112
|
+
[
|
|
113
|
+
session('alpha', 'session-a', issueUrlA),
|
|
114
|
+
session('alpha', 'session-b', issueUrlB),
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
issue(issueUrlA, IN_TMUX_STATUS_NAME),
|
|
118
|
+
issue(issueUrlB, IN_TMUX_STATUS_NAME),
|
|
119
|
+
],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const alpha = result.counts.find((entry) => entry.name === 'alpha');
|
|
123
|
+
expect(alpha?.count).toBe(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('reports counts per token across multiple tokens', () => {
|
|
127
|
+
const result = useCase.run(
|
|
128
|
+
[candidate('alpha'), candidate('beta')],
|
|
129
|
+
[
|
|
130
|
+
session('alpha', 'session-a', issueUrlA),
|
|
131
|
+
session('beta', 'session-b', issueUrlB),
|
|
132
|
+
],
|
|
133
|
+
[
|
|
134
|
+
issue(issueUrlA, IN_TMUX_STATUS_NAME),
|
|
135
|
+
issue(issueUrlB, AWAITING_QUALITY_CHECK_STATUS_NAME),
|
|
136
|
+
],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
expect(result.counts).toEqual([
|
|
140
|
+
{ name: 'alpha', token: 'fake-token-alpha', count: 1 },
|
|
141
|
+
{ name: 'beta', token: 'fake-token-beta', count: 0 },
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const beta = result.counts.find((entry) => entry.name === 'beta');
|
|
145
|
+
expect(beta?.count).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('reports zero for a token with no matching interactive session', () => {
|
|
149
|
+
const result = useCase.run(
|
|
150
|
+
[candidate('lonely')],
|
|
151
|
+
[session('other', 'session-x', issueUrlA)],
|
|
152
|
+
[issue(issueUrlA, IN_TMUX_STATUS_NAME)],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const lonely = result.counts.find((entry) => entry.name === 'lonely');
|
|
156
|
+
expect(lonely?.count).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('ignores a session whose issue url is not present among the issues', () => {
|
|
160
|
+
const result = useCase.run(
|
|
161
|
+
[candidate('alpha')],
|
|
162
|
+
[session('alpha', 'session-a', issueUrlB)],
|
|
163
|
+
[issue(issueUrlA, IN_TMUX_STATUS_NAME)],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const alpha = result.counts.find((entry) => entry.name === 'alpha');
|
|
167
|
+
expect(alpha?.count).toBe(0);
|
|
168
|
+
});
|
|
169
|
+
});
|